.Net Core 3.1 Üzerinde Controller ve Action Bazlı Kullanıcı Yetkileri
Selamlar,
https://youtu.be/kt7t6ufM7jY
Bu makalede .Net 3.1 üzerinde çalışan bir WebApi servisinde, userlara her bir sayfa için ve her sayfaya ait methodlar için verilecek yetkileri, detaylıca inceleyeceğiz. Bu makalede Database olarak “Microsoft Sql Server 2014” kullanılmıştır. Eğer makinanızda Northwind database yok ise, bu link’deki script’i Sql Management Studio’da çalıştırarak, indirebilirsiniz. Daha sonra User, RoleGroups, Roles ve UserRoles tablolarını, yine aşağıdaki scripti çalıştırarak, dataları ile birlikte oluşturabilirsiniz. Amaç Türkiyedeki gerçek hayat senaryolarını size en doğru şekilde yansıtabilmektir :) Normalde, makaleler CodeFirst yazarak işlemlerine devam etmektedir. Ama gerçek hayatta işler biraz farklı olabilir. Siz bir firmaya gittinizde, Database zaten ordadır. Bussines DB üzerinde şekillendirilerek çoktaaan çıkarılmıştır :) Bu nedenle bu makalede, ben pek haz etmesem de, DB First kullanılmıştır. Ayrıca katmanlı mimari ve bir projede olması gereken Business Logic kavramlarına, biraz olsun girilmiştir.
http://www.borakasmer.com/projects/Users&RolesScript.txt
Database
Users: Kullanıcı bilgilerinin doldurulduğu tablodur.
RoleGroups: Aslında her bir sayfaya, yani Controller’a karşılık gelen yetki tablosudur.
Roles: Sayfa üzerindeki, her bir Action’a karşılık gelen yetki tablosudur. Burada dikkat edilmesi gereken bir husus da, RoleID alanının “bigint” olmasıdır. Bu kısım makalenin devamında detaylıca anlatılacaktır.
UserRoles: Her bir kullanıcının, Controller ve Action bazında yetkisi, bu tablo üzerinde tanımlanır. Burada da Roles alanı, yukarıda bahsedilen Role tablosundaki RoleID gibi, “bigint“‘dir.
.Net Core Proje Oluşturma
Şimdi gelin Visual Studio2019 ile WebApi .Net Core projesini, aşağıda görüldüğü gibi oluşturalım.
Proje Tipi olarak, API seçilir.
Properties/launchSettings.json: Aşağıdaki gibi değiştirilir. iisSettings‘de sslPort‘u 0 yapılarak projenin “http“‘den çalışması sağlanmıştır. Ayrıca, profiles sekmesinden IIS Express ==> launchURL :”api/person” olarak değiştirilir. Son olarak istenir ise uygulama, “IIS Express” yerine “UserRoles” profilinden de çalıştırılabilir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
{ "$schema": "http://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, "iisExpress": { "applicationUrl": "http://localhost:1923", "sslPort": 0 } }, "profiles": { "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, "launchUrl": "api/person", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "UserRoles": { "commandName": "Project", "launchBrowser": true, "launchUrl": "api/person", "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } } |
Ayrıca WeatherForecastController ==> PersonController olarak değiştirilir. WeatherForecastController, proje oluşturulduğunda default olarak gelen servisdir.
PersonController: Default olarak gelen Controller, aşağıdaki gibi değiştirilir. Bu sayfa, tamamen test amaçlı olduğu gibi bırakılmıştır. Makalenin devamında, ihtiyaca göre değiştirilecektir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace UserRoles.Controllers { [ApiController] [Route("api/[controller]")] public class PersonController : ControllerBase { private static readonly string[] Summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; private readonly ILogger<PersonController> _logger; public PersonController(ILogger<PersonController> logger) { _logger = logger; } [HttpGet] public IEnumerable<WeatherForecast> Get() { var rng = new Random(); return Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = DateTime.Now.AddDays(index), TemperatureC = rng.Next(-20, 55), Summary = Summaries[rng.Next(Summaries.Length)] }) .ToArray(); } } } |
Uygulama IIS Express profili ile çalıştırıldığında, aşağıdaki gibi bir ekran ile karşılaşılır.
Database First DB Entity Oluşturma
Şimdi DB adında yeni bir Library Projesi oluşturalım. Amaç Northwind database’i ile alakalı Context’i ve buna bağlı Entityleri oluşturmaktır. Aşağıda görüldüğü gibi, DB adında “Class Library (.Net Core)” projesi oluşturulmuştur.
“Entities” adında yeni bir folder, aşağıdaki gibi oluşturulur.
Öncelikle Nuget’den aşağıdaki kütüphaneler indirilir. Makale yazıldığı an itibari ile, v3.1.1 versiyonları mevcuttur.
Eğer makinanızda yok ise, EF Core Tools’un yüklenmesi gerekmektedir. Aşağıda görüldüğü gibi, test makinasında eski bir versiyonu bulunmakta idi.
EF Core Tools’nun güncellenmesi için, önce var olan sürümün, aşağıdaki komut ile silinmesi gerekmektedir.
1 |
dotnet tool uninstall --global dotnet-ef |
Daha sonra aşağıdaki komut ile makalenin yazıldığı an itibari ile, “3.1.0” versiyonunu yüklenir. “3.1.1” versiyonu ile uyuşmazlık olduğu için, bu versiyon özellikle yüklenmiştir!
1 |
dotnet tool update --global dotnet-ef --version 3.1.0 |
Command prompt’a geçip, yukarıda görüldüğü gibi DB folderının altına gelinir. Ve aşağıdaki komut ile localde çalışan SqlDB’den Northwind database’ine bağlanılıp, tüm entityler ve NorthwindContex’i, ilgili folder altında oluşturulur. Bu işleme, “Database First” denilmektedir. Var olan bir DB olduğu için, bu yöntem tercih edilmiştir.
1 |
dotnet ef dbcontext scaffold "Server=DESKTOP-NUCDV6U;Database=Northwind;Trusted_Connection=True;" Microsoft.EntityFrameworkCore.SqlServer --output-dir Entities --force |
Aşağıda, otomatik oluşan Entityler, ve Northwind DBContext’i gözükmektedir.
.Net Core’da, Dependency Injection ile ilgili DB Context’in ayağa kaldırılması için, Startup.cs’de aşağıdaki kodun tanımlanması gerekmektedir.
Startup.cs:
1 2 3 4 5 6 7 |
.. public void ConfigureServices(IServiceCollection services) { services.AddDbContext<NorthwindContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); services.AddControllers(); } .. |
Ayrıca ConnectionString için appsettings.json, aşağıdaki gibi değiştirilir.
appsettings.json: Northwind database’ine bağlanmak için, “ConnectionStrings” tanımlaması aşağıdaki gibi eklenmiştir.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } }, "ConnectionStrings": { "DefaultConnection": "Data Source=DESKTOP-NUCDV6U;initial catalog=Northwind;Trusted_Connection=True;" }, "AllowedHosts": "*" } |
Repository Oluşturma
Şimdi sıra geldi, yetki amaçlı Roles servisinin yazılmasına. Her bir servis, güvenlik nedeni ile doğrudan DBContext’e erişmemeli, araya Repository katmanı ile data etkileşimine girmelidir. O zaman gelin, Repository adında, yeni bir Class Library projesi oluşturalım.
IRepository: Repository katmanının türeyeceği Interfacedir. Aşağıda görüldüğü gibi, bir servis içinde Entity ile yapılabilecek genel işlemler, global bir sınıf altında toplanmıştır.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
using System; using System.Collections.Generic; using System.Text; namespace Repository { using System; using System.Collections.Generic; using System.Linq; namespace Repository { public interface IRepository<T> { IQueryable<T> Table { get; } IQueryable<T> TableNoTracking { get; } T GetById(object id); void Insert(T entity); void Insert(IEnumerable<T> entities); void Update(T entity); void Update(IEnumerable<T> entities); void Delete(T entity); void Delete(IEnumerable<T> entities); IEnumerable<T> GetSql(string sql); } } } |
Repository: Burada esas amaç, hangi Entity alınır ise alınsın, hepsinde ortak geçerli olan DB işlemleri için tek bir kodun yazılması, ve kod tekrarından kaçınılmasıdır.
- “Table” : DBContext’deki, Entity’ye karşılık gelmektedir.
- “Entities” : NorthwindContext’in kendisidir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
using System; namespace Repository { using DB.Entities; using Microsoft.EntityFrameworkCore; using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; namespace Repository { public class Repository<T> : IRepository<T> where T : class { private readonly NorthwindContext _context; private DbSet<T> _entities; public Repository(NorthwindContext context) { _context = context; _entities = context.Set<T>(); } public virtual T GetById(object id) { return Entities.Find(id); } public void Insert(T entity) { if (entity == null) throw new ArgumentNullException(nameof(entity)); _context.Entry(entity).Property("UsedTime").CurrentValue = DateTime.Now; //--------------- _entities.Add(entity); _context.SaveChanges(); } public virtual void Insert(IEnumerable<T> entities) { if (entities == null) throw new ArgumentNullException(nameof(entities)); foreach (var entity in entities) Entities.Add(entity); _context.SaveChanges(); } public void Update(T entity) { if (entity == null) throw new ArgumentNullException(nameof(entity)); _context.SaveChanges(); } public virtual void Update(IEnumerable<T> entities) { if (entities == null) throw new ArgumentNullException(nameof(entities)); _context.SaveChanges(); } public void Delete(T entity) { if (entity == null) throw new ArgumentNullException(nameof(entity)); Entities.Remove(entity); _context.SaveChanges(); } public virtual void Delete(IEnumerable<T> entities) { if (entities == null) throw new ArgumentNullException(nameof(entities)); foreach (var entity in entities) Entities.Remove(entity); _context.SaveChanges(); } public IEnumerable<T> GetSql(string sql) { return Entities.FromSqlRaw(sql); } public virtual IQueryable<T> Table => Entities; public virtual IQueryable<T> TableNoTracking => Entities.AsNoTracking(); protected virtual DbSet<T> Entities => _entities ?? (_entities = _context.Set<T>()); } } } |
Kişilerin yetkisini, yani alınan RoleModel’i, aşağıdaki gibi oluşturalım.
RoleModel: Aşağıda görüldüğü gibi, bir User’ın yetkileri UserID, RoleGroupID(Controller) yani sayfa ve RoleID(Action) yani method bazında tutulmuştur.
NOT: Normalde her model, bir BaseModel’den türetilmelidir. Burada, konu dışı olduğu için çıkarılmıştır.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
using System; using System.Collections.Generic; using System.Text; namespace Core.Models.Roles { public class RoleMode { public int UserID { get; set; } public int RoleGroupID { get; set; } public Int64 RoleID { get; set; } public string GroupName { get; set; } public string RoleName { get; set; } } } |
Startup.cs: Yine repostroy katmanının Dependency Injection ile ayağa kaldırılması için, Startup.cs’de aşağıdaki kodun tanımlanması gerekmektedir. “IRepository” AddScoped ile eklenmiştir.
1 2 3 4 5 6 7 8 |
.. public void ConfigureServices(IServiceCollection services) { services.AddDbContext<NorthwindContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); .. } .. |
Servis Oluşturma
IServiceResponse : Her servisin döneceği, ortak modelin interface’idir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
using System.Collections.Generic; namespace Service { public interface IServiceResponse<T> { IList<T> List { get; set; } T Entity { get; set; } int Count { get; set; } bool IsSuccessful { get; set; } } } |
ServiceResponse : Her servisin döneceği, ortak bir model olmalıdır. Bu örnekte de bu model, ServiceResponse sınıfıdır.
- “List” : Kayıt kümesi dönüleceği zaman kullanılır. Örneğin bir sayfa üzerindeki tüm yetkiler liste şeklinde bu property’e atanabilir.
- “Entity” : Tek bir kaydın dönülmesi durumunda, kullanılır.
- “Count” : Dönen toplam kayıt sayısıdır.
- “IsSuccessful” : İşlemin başarılma durumunu gösterir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
using Newtonsoft.Json; using Service; using System; using System.Collections.Generic; using System.Linq; namespace Core.ApiResponse { [Serializable] public class ServiceResponse<T> : IServiceResponse<T> { public IList<T> List { get; set; } [JsonProperty] public T Entity { get; set; } public int Count { get; set; } public bool IsSuccessful { get; set; } } } |
IRoleService: User yetkisinin, sayfa bazında hangi methodlar için geçerli olduğunu ve method bazında yetkili olup olmadığını, aşağıdaki Inteface’de görülen methodlar ile kontrol edilir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
using Core.Models.Roles; using Service; using System; using System.Collections.Generic; using System.Text; namespace Services.Roles { public interface IRoleService { public IServiceResponse<RoleModel> GetRoleById(int userId, int roleGroupID, Int64 roleID); public IServiceResponse<RoleModel> GetRoleListByGroupId(int userId, int roleGroupID); } } |
*Bitwise Öteleme
Şimdi tam burada bir ara verip, her bir user’a verilen yetki konusunu konuşalım. Bu makalede, standart yöntemlerin çok dışında bir yetkilendirme metodolojisi kullanılmıştır. Amaç, size tamamen başka bir bakış açısı katmaktır. Okullarda anlatılan mühendislik konularının, gerçek hayatta nasıl kullanılabileceğini gösteren en somut örneklerinden biri de, bu konudur. Biri, okul önemli dediğinde çıkarıp ona sunabileceğiniz makalelerden biri de bu diyebilirim. Dikkatinizi çekildi isem :), şimdi gelelim işin özüne.
1 |
select * from [dbo].[UserRoles] |
Yukarıdaki sorgu sonucuna bakılığında, RoleGroup yani sayfa başına yetki için, sadece bir sayı yazıldığını “7” görebilirsiniz. Bu sayı, sayfa üzerinde user’a verilen tüm yetkilere karşılık gelmektedir. Peki nasıl ?
1 |
select * from Roles |
Sayfalar üzerindeki tüm yetkiler sorgu olarak çekildiğinde, yukarıdaki gibi bir sonuç ile karşılaşılır. Sayfa bazında tüm yetkiler yani RoleID, 2‘nin katları şekilde artmaktadır. En fazla 63 eleman bu şekilde tanımlanabilmektedir. Çünkü, C# dilinde Maximum sayı büyüklüğü 2^63’e kadar yani, “double” tipine karşılık gelmektedir. Bu tipin Sql’deki karşılığı, “bigint“‘dir. Sanırım makalenin başında tabloları tanımlarken, RoleID’nin tipini neden bigint olarak atadığımızı, artık anlamışsınızdır :)
Bitwise ötelemede istenen yetkilere karşılık gelen RoleID toplanır, ve toplamına karşılık gelen sonuç User’a atanır. Örneğin, yukarıdaki sorgudan yola çıkar isek:
Örnek: Sayfa 2, yani GroupID 2’için ==> “GetCustomer() => 1 ve GetCustomerList() => 4 yetkileri bir user’a verilmek istenir ise, toplamı olan “5” sayısı, Roles alanına atanır.
O zaman “select * from [dbo].[UserRoles]” sorgusunda, UserID = 1 için ==> Roles 7 değeri alınarak, ilgili client’a, Sayfa 2 için tüm yetkilerin verilmesi sağlanmıştır. 1+2+4 = 7 (GetCustomer + GetCustomerById + GetCustomerList)
Peki aranan yetkinin, ilgili kayıt kümesinde olup olmadığına nasıl bakılır?
Örneğin, GetCustomerList() yetkisinin RoleID’si 4‘dür. UserID = 1 için tüm yetkilerin değeri 7‘dir. O zaman gelin 4 yetkisini, 7’nin içinde var mı diye bakalım. Aşağıdaki koşul, “true” değerini dönecektir. Bitwise öteleme işte tam da budur. Esas amca, User’ın her bir yetkisi için yeni bir satırın oluşmasını engellemek ve data kalabalığına son vermektir. Hatta her sayfa, yani Controller için çoklu yetki verilmese idi, farklı bir tablo yapılmasına gerek kalmayacak, User tablosuna tek bir kolonun açılması yeterli olacaktı.
if(4 == (7 & 4)) ==> true
Bitwise Öteleme hakkında, daha detaylı bilgiye buradan erişebilirsiniz. https://medium.com/@vbtbilgi/c-sql-iterations-382b46df1d39
Servisler
RoleService: Sayfaya girmek isteyen bir kişinin ya da sayfada herhangi bir yere tıklayan kişinin, yetkisinin olup olmadığına bakıldığı yerdir.
- “_userRolesRepository” : User’a ait rollerin, tutulduğu tablodur. UserRole tablosuna karşılık gelir.
- “_rolesRepository” : Sayfalara göre guruplanmış, tüm rollerin listesinin tutulduğu tablodur. Roles tablosuna karşılık gelir.
- “GetRoleById()” : User’ın, ilgili Action’a yetkisinin olup olmadığına “Bitwise Öteleme” ile bakıldığı yerdir.
- “response” : Her servisin döndüğü ortak model olan, ServiceResponse modelidir.
- “model” : User’ın yetkilerinin tutulduğu, RoleModel’dir.
- “var userRole = _userRolesRepository.Table.Include(r => r.RoleGroup).FirstOrDefault(ur => ur.UserId == userId && ur.RoleGroupId == roleGroupID)”: İlgili user(UserId) ve Sayfaya(RoleGroupId)’ye göre, client’ın o sayfa üzerindeki yetkilerinin toplamı çekilir.
- “if (roleID == (userRole.Roles & roleID))” : O sayfa için gerekli olan yetki(roleID), bitwise ile User Rollerinde var mı, diye bakılır. Kısaca User, ilgili sayfaya girebilir mi diye kontrol edilir.
- “var role = _rolesRepository.Table.Where(r => r.RoleId == roleID).FirstOrDefault()” : Aranan role’ün kaydı, Roles tablosundan çekilir.
- “model = new RoleModel() { Id = role.Id, RoleName = role.RoleName, RoleGroupID = (int)userRole.RoleGroupId, RoleID = roleID, UserID = userId, GroupName = userRole.RoleGroup.GroupName }” : Methodun dönmesi gereken RoleModel, aranan Role’e göre doldurulur.
- ” response.Entity = model”: Her servisin döndüğü Response Model’den, tekil kayıt alan Entity, RoleModel ile doldurularak geri dönülür.
- “GetRoleListByGroupId()”: Bu methodda amaç, User’ın o sayfaya ait tüm yetkilerinin çekilmesidir. Herbir yetki, farklı bir satır olarak geri dönülecektir. Böylece, sayfa daha ilk yüklenirken, kullanıcı sadece yetkili olduğu alanları görebilecektir.
- “var response = new ServiceResponse<RoleModel>()” : Her servisin döndüğü, ServiceResponse modeli oluşturulur.
- “List<RoleModel> model = new List<RoleModel>()” : Bu sefer servisden geri dönülecek, ServiceResponseModel’inin List property’si, List of RoleModel ile doldurulacaktır.
- “var userRole = _userRolesRepository.Table.FirstOrDefault(ur => ur.UserId == userId && ur.RoleGroupId == roleGroupID)” : User’ın o sayfa için olan tüm yetkileri, tek bir değer olarak alınır.
- “var allRoles = _rolesRepository.Table.Include(r => r.Group).Where(r => r.GroupId == roleGroupID).ToList()” : İlgili sayfaya ait tüm roller, kontrol edilmek amacı ile çekilir.
- “foreach (var role in allRoles)” : Çekilen tüm roller, tek teke gezilir.
- “ if (role.RoleId == (userRole.Roles & role.RoleId))” : Sıra ile bakılan herbir role, User’ın rollerinden biri mi bitwise ile kontrol edilir.
- “model.Add(new RoleModel() { Id = role.Id, RoleName = role.RoleName, RoleGroupID = (int)role.GroupId, RoleID = (int)role.RoleId, UserID = userId, GroupName = role.Group.GroupName })”: Kontrol edilen yetki, User’ın role’ü ise, yeni bir RoleModel olarak model listesine eklenir.
- ” response.List = model”: Dolan List of RoleModel (List<RoleModel>), bu sefer Response Model’in List property’sine atanır.
- “return response” : Kullanıcının sayfa üzerindeki tüm yetkileri, ServiceResponse olarak geri dönülür.
RoleService:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
using Core.Models.Roles; using System; using System.Linq; using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Service; using Repository.Repository; namespace Services.Roles { public class RoleService : IRoleService { private readonly IRepository<DB.Entities.UserRoles> _userRolesRepository; private readonly IRepository<DB.Entities.Roles> _rolesRepository; public RoleService(IRepository<DB.Entities.UserRoles> userRolesRepository, IRepository<DB.Entities.Roles> rolesRepository) { _userRolesRepository = userRolesRepository; _rolesRepository = rolesRepository; } public IServiceResponse<RoleModel> GetRoleById(int userId, int roleGroupID, Int64 roleID) { var response = new ServiceResponse<RoleModel>(); RoleModel model = new RoleModel(); var userRole = _userRolesRepository.Table .Include(r => r.RoleGroup) .FirstOrDefault(ur => ur.UserId == userId && ur.RoleGroupId == roleGroupID); if (userRole != null) { if (roleID == (userRole.Roles & roleID)) { var role = _rolesRepository.Table.Where(r => r.RoleId == roleID).FirstOrDefault(); if (role != null) { model = new RoleModel() { Id = role.Id, RoleName = role.RoleName, RoleGroupID = (int)userRole.RoleGroupId, RoleID = roleID, UserID = userId, GroupName = userRole.RoleGroup.GroupName }; } } response.Entity = model; } return response; } public IServiceResponse<RoleModel> GetRoleListByGroupId(int userId, int roleGroupID) { var response = new ServiceResponse<RoleModel>(); List<RoleModel> model = new List<RoleModel>(); var userRole = _userRolesRepository.Table.FirstOrDefault(ur => ur.UserId == userId && ur.RoleGroupId == roleGroupID); if (userRole != null) { var allRoles = _rolesRepository.Table .Include(r => r.Group) .Where(r => r.GroupId == roleGroupID).ToList(); foreach (var role in allRoles) { if (role.RoleId == (userRole.Roles & role.RoleId)) { model.Add(new RoleModel() { Id = role.Id, RoleName = role.RoleName, RoleGroupID = (int)role.GroupId, RoleID = (int)role.RoleId, UserID = userId, GroupName = role.Group.GroupName }); } } response.List = model; } return response; } } } |
Startup.cs: Yazılan“RoleService“, projeye yine “AddTransient<IRoleService, RoleService>” olarak Startup.cs altında tanımlanarak, aşağıdaki gibi eklenmesi gerekmektedir.
1 2 3 4 5 6 7 |
public void ConfigureServices(IServiceCollection services) { services.AddDbContext<NorthwindContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); services.AddTransient<IRoleService, RoleService>(); services.AddControllers(); } |
Actionlar
Şimdi sıra geldi, PersonController’da Userlar için yetki tanımlanan Actionların oluşturulmasına:
Sql üzerinde aşağıdaki sorgular yapılınca, Customer için yazılacak methodların listesi, yetkilerden çekilir. Neden yetkilerden, yazılacak methodlara gittiği mi soracak olursanız, gerçek hayatta bilginin ne kadar acayip yerlerden :) gelebileceğine bir örnek vermek için derim.
1 2 |
select * from [dbo].RoleGroups select * from [dbo].[Roles] where GroupID=2 |
Öncelikle gelin, geri dönülecek PersonModel’i oluşturalım: Model folder’ı altında, aşağıdaki sınıf oluşturulur.
- Date : Sınava giriş tarihi.
- Age : Yaşı
- Score : 100’lük sayı değeri ile gelen sonuç, 5’lik sınav sonucuna dönüştürülür.
- Name: Katılımcı adı.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
using System; namespace Core.Models.Person { public class PersonModel { public string Date { get; set; } public int Age { get; set; } private int _score; public int Score { set => _score = value; get => (int)((_score * 5) / 100); } public string Name { get; set; } } } |
Örnek çıktı:
Aşağıda Names adında test amaçlı, Mocap isim datası üretilmiştir.
1 2 3 4 5 6 7 8 9 10 11 12 |
public class PersonController : ControllerBase { private static readonly string[] Names = new[] { "Mozart", "Linus", "Bill", "Chaplin", "Martin", "Bob", "Tesla", "Planck", "Einstein", "Ada" }; public PersonController() { } } |
Sayfaya ilk gelinildiğinde, GetCustomer() methodu çalıştırılır.
- “var rng = new Random()” : Belirlenen aralıkta, rastgele değer üretmek için kullanılır.
- “return Enumerable.Range(1, 5).Select(index => new PersonModel” : 5 adet PersonModel, üretilir.
- “Date = DateTime.Now.AddDays(index).ToShortDateString()” : Sıra ile, günümüzden birer gün olarak artan tarih girilir.
- “Age = rng.Next(18, 56)” : 18- 56 arası rastgele bir yaş üretilir.
- “Score = rng.Next(1, 101)” : Rastgele 1 – 100 arası sınav sonucu üretilir.
- “Name = Names[rng.Next(0, 10)]” : Dummy olarak üretilen 9 isimden 1 tanesi, rastgele seçilir.
- “.ToArray()” En son 5 elemanlı PersonModel, dizi olarak geri dönülür.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
[HttpGet] public IEnumerable<PersonModel> GetCustomer() { var rng = new Random(); return Enumerable.Range(1, 5).Select(index => new PersonModel { Date = DateTime.Now.AddDays(index).ToShortDateString(), Age = rng.Next(18, 56), Score = rng.Next(1, 101), Name = Names[rng.Next(0, 10)] }) .ToArray(); } |
GetCustomerById : Bu method da GetCustomer()’ın aynısıdır. Terk farkı, 1 tek kişinin kaydı geri dönülür.
- “Name = Names.Any(n => n == personID) == false ? “Bora” : personID” : Kayıdı alınmak istenen kişinin adı, mocap list’de yok ise, default olarak “Bora” dönülür.
1 2 3 4 5 6 7 8 9 10 11 12 |
[HttpGet("GetCustomerById/{personID}")] public PersonModel GetCustomerById(string personID) { var rng = new Random(); return new PersonModel { Date = DateTime.Now.ToShortDateString(), Age = rng.Next(18, 56), Score = rng.Next(1, 101), Name = Names.Any(n => n == personID) == false ? "Bora" : personID }; } |
GetCustomerList : Bu method da, GetCustomer() methodunun aynısıdır. Sadece listelenecek kayıt sayısı, parametre olarak alınmıştır.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
[HttpGet("GetCustomerList/{count}")] public IEnumerable<PersonModel> GetCustomerList(int count) { var rng = new Random(); return Enumerable.Range(1, count).Select(index => new PersonModel { Date = DateTime.Now.AddDays(index).ToShortDateString(), Age = rng.Next(18, 56), Score = rng.Next(1, 101), Name = Names[rng.Next(0, 10)] }) .ToArray(); } |
Enumlar
Şimdi sıra geldi, yetkileri belirlemeye:
Öncelikle Solution’da “Class Library” Core adında yeni Proje oluşturalım. Ve ilk sınıfımız Enumlar olacaktır. Solution’ın son görünümü, aşağıdaki gibidir.
Core/Enum.cs: Amaç, ilgili methodlar için yetki sorgularken, ne olduğu anlaşılmayan sayılar yerine Enumların kullanılmasıdır. Yetkiler Gurup yani Controller ve Page yani Action şeklinde verildiği için, Enum tanımlamaları da Controller ve Action bazında yapılmıştır.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
using System; namespace Enums { public class Enums { public enum RoleGroup { Employee = 1, Customer = 2 } public enum CustomerRoles { GetCustomer = 1, GetCustomerById = 2, GetCustomerList = 4 } public enum EmployeeRoles { GetEmployees = 1, GetEmployeeWithTerritories = 2, UpdateEmployee = 4 } } } |
Custom Action Filter Oluşturma
Şimdi sıra geldi bir işaretleyici yapmaya. Yani ilgili Action’ı işaretlemek için, “Custom Action Filter” yapmaya. Bu şekilde belli bir yetki ile girilmesi gereken sınıfları belirleyip, yetki kontrolü yapacağız. Filter’a atanacak parametreler, yukarıdaki Enum değerler olacaktır.
Core/RoleAttribute: Aşağıda görüldüğü gibi bir RoleAttribute’ü, Core projesi içinde oluşturulmuştur.
2 parametresi vardır. 1. roleGroupID, 2. roleID. Amaç, client’ın ilgili Controller’daki Action’a, yetkisinin olup olmadığının bakılmasıdır. Dikkat edilir ise, roleID => Int64‘dür. Çünkü yetki ID’leri 2’nin katı şeklinde 2^63’e kadar alınabilmektedir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace TemplateProject.Infrastructure { [AttributeUsage(AttributeTargets.All)] public class RoleAttribute : Attribute { int roleGroupID; Int64 roleID; public RoleAttribute(int RoleGroupID, Int64 RoleID) { this.roleGroupID = RoleGroupID; this.roleID = RoleID; } public int RoleGroupID { get { return roleGroupID; } } // property to get description public Int64 RoleID { get { return roleID; } } } } |
Sınırlama Getirme
Şimdi sıra geldi PersonController’dan bir Action’a, sınırlama getirmeye:
[RoleAttribute((int)RoleGroup.Customer, (Int64)CustomerRoles.GetCustomerList)]
Person/GetCustomerList() : Aşağıda görüldüğü gibi, ilgili GetCustomerList() Action’ına [RoleAttribute]’ü eklenmiştir. Parametre olarak RoleGroup.Customer cotroller’ının, CustomerRoles.GetCustomerList action’ı verilmiştir. Gönderilen parametrelere göre ilgili Action’ın RoleID‘değeri, User’ın o Controller için yetkiler toplamı olan, “Roles” değeri içinde bitwise olarak aranır.
*Son olarak makalenin devamında yazılacak olan Custom Filter’ın da, “[ServiceFilter(typeof(PermissionFilter))]” olarak tanımlaması gerekmektedir. Böylece ilgili methodun çağrılmadan önce, “Permission Filter“‘a girmesi sağlanmıştır.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
[ServiceFilter(typeof(PermissionFilter))] [RoleAttribute((int)RoleGroup.Customer, (Int64)CustomerRoles.GetCustomerList)] [HttpGet("GetCustomerList/{count}")] public IEnumerable<PersonModel> GetCustomerList(int count) { var rng = new Random(); return Enumerable.Range(1, count).Select(index => new PersonModel { Date = DateTime.Now.AddDays(index).ToShortDateString(), Age = rng.Next(18, 56), Score = rng.Next(1, 101), Name = Names[rng.Next(0, 10)] }) .ToArray(); } |
Custom Filter
Şimdi sıra geldi Custom bir Filter yapmaya.
PermissionFilter: Amaç, daha webersivisine gelmeden önce, araya girip “OnActionExecuting()” methodunda, gelen User’ın ilgili Action için yetkisinin olup olmadığının kontrol edilmesidir. Tüm Filterlar, “IActionFilter”‘dan türetilirler ve 2 methodun inherit edilmesi gerekmektedir. OnActionExecuting() ve OnActionExecuted().
- “public bool HasRoleAttribute(FilterContext context)” : Gidilmek istenen Action’ın başında, “RoleAttribute” ile işaretlendi mi diye bakılır. Ancak bu attribute ile işaretlenen Actionlar için, Role kontrolü yapılacaktır. Diğer actionlar için kısıtlama yoktur.
- “public void OnActionExecuting(ActionExecutingContext context)” : Webservisine gitmeden önce, girilen methoddur. Esas yetki kontrolü burada yapılır.
- “int.TryParse(context.HttpContext.Request.Headers[“UserId”].FirstOrDefault(), out var userId)” : Action’a erişmek isteyen client’ın UserID’si, header’dan alınır.
- *“var arguments = ((ControllerActionDescriptor)context.ActionDescriptor).MethodInfo.CustomAttributes.FirstOrDefault(fd => fd.AttributeType == typeof(RoleAttribute)).ConstructorArguments”: Erişilmek istenen methodun tepesinde tanımlanan [RoleAttribute]’a atanan parametreler, burada yakalanır.
- “int roleGroupID = (int)arguments[0].Value; Int64 roleID = (Int64)arguments[1].Value” : Yakalanan “RoleGroupID” ve “RoleID“, değişkenlere atanır.
- “RoleModel role = _roleService.GetRoleById(userId, roleGroupID, roleID).Entity” : Client’ın, ilgili method için yetkisinin olup olmadığına burada bakılır. Kısacası “UserRoles” tablosundan, UserID ve RoleGroupID parametrelerine göre tüm yetkilerin döndüğü Roles değeri alınır. Daha sonra RoleName ve RoleGroupID’ye göre, “Roles” tablosundan RoleID değeri bulunur. Ve son olarak bulunan bu RoleID, yetkilerin döndüğü Roles değeri içinde, bitwise ile aranır. Eğer herhangi bir kayıt bulunur ise, geriye RoleModel dönülür.
- “if (role.Id == 0)” : Eğer herhangi bir kayıt bulunamaz ise, Forbidden 403 cevabı geri dönülür.
- “context.Result = new ObjectResult(context.ModelState) { Value = “You are not authorized for this page”, StatusCode = Microsoft.AspNetCore.Http.StatusCodes.Status403Forbidden }” : HttpContext’in result’ına, Custom bir ObjectResult dönülür. User’ın bu methoda yetkisinin olmadığı, “Status403Forbidden” StatusCode’u şeklinde bildirilir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
using Core; using Core.Models.Roles; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Filters; using Services.Roles; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace UserRoles { public class PermissionFilter : IActionFilter { private readonly IRoleService _roleService; public PermissionFilter(IRoleService roleService) { _roleService = roleService; } public void OnActionExecuting(ActionExecutingContext context) { int.TryParse(context.HttpContext.Request.Headers["UserId"].FirstOrDefault(), out var userId); //Role Yetkisine bakılır. if (HasRoleAttribute(context)) { try { var arguments = ((ControllerActionDescriptor)context.ActionDescriptor).MethodInfo.CustomAttributes.FirstOrDefault(fd => fd.AttributeType == typeof(RoleAttribute)).ConstructorArguments; int roleGroupID = (int)arguments[0].Value; Int64 roleID = (Int64)arguments[1].Value; RoleModel role = _roleService.GetRoleById(userId, roleGroupID, roleID).Entity; if (role.Id == 0) { //Forbidden 403 Result. Yetkiniz Yoktur.. context.Result = new ObjectResult(context.ModelState) { Value = "You are not authorized for this page", StatusCode = Microsoft.AspNetCore.Http.StatusCodes.Status403Forbidden }; return; } } catch (Exception ex) { Console.WriteLine(ex.Message); } } } public void OnActionExecuted(ActionExecutedContext context) { throw new NotImplementedException(); } public bool HasRoleAttribute(FilterContext context) { return ((ControllerActionDescriptor)context.ActionDescriptor).MethodInfo.CustomAttributes.Any(filterDescriptors => filterDescriptors.AttributeType == typeof(RoleAttribute)); } } } |
Yukarıda tanımlı PermissionFilter’ın da dependency injection ile ayağa kaldırılabilmesi için, Startup.cs’de aşağıdaki gibi “AddScoped<PermissionFilter>” şeklinde tanımlanması gerekmektedir.
Kullanım Şekli: “[ServiceFilter(typeof(PermissionFilter))] ” ilgili filter, Action ya da Controller üstüne konarak methoda girmeden önce “HasRoleAttribute” işaretlemesi var ise, yetki kontrolünün yapılması sağlanmıştır.
Startup.cs: Son hali aşağıdaki gibidir.
1 2 3 4 5 6 7 8 |
public void ConfigureServices(IServiceCollection services) { services.AddDbContext<NorthwindContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); services.AddTransient<IRoleService, RoleService>(); services.AddScoped<PermissionFilter>(); services.AddControllers(); } |
Kısaca yaptıklarımız bir toparlarsak :
- Öncelikle DB First mantığı ile, Database, tablolar ve test amaçlı datalar oluşturulmuştur.
- Daha sonra bir .Net Core projesi oluşturularak ayağa kaldırılmıştır.
- DB amaçlı, Class Library şeklinde yeni bir proje oluşturulur. EF Core Tools yardımı ile, Database ve Tablolara karşılık gelen DB Context ve Entityler tek bir komut ile yaratılır.
- Tüm Entityler için geçerli olan bir General Repository katmanı oluşturulur ve tüm DB işlemleri bu katmanda yapılır. Aynı zamanda, DB Context’e direk Servisden erişim güvenlik amacı ile araya bir katman konarak engellenmiş olunur.
- Bu Repository katmanının kullanıldığı Servis katmanı oluşturulur. İlgili servisde, yetki ile ilgili tüm sorgulamalar yapılır. Tüm yetkiler performans amaçlı, tek bir kolona atanmıştır. Bitwise öteleme ile aranacak yetki, ilgili kolondan denenerek bakılır. Tüm enitityler için servis, tek bir result tipi dönmektedir. Bu da “ServiceResponse” modeldir. Böylece projenin her yerinde, aynı tip model ile çalışılır.
- Tüm parametrik değerler için yani Controller ve Actionlar için Enum değerler üretilir.
- Yetkilerin, tanımlanan methodlar veya Controller için bakılması için, ayırt edici “RoleAttribute” oluşturulur. Tüm methodlar için yetkiye, bu işaretleyici yardımı ile bakılır.
- Custom PermissionFilter : Başına RoleAttribute konan methodların, çalıştırılmadan önce devreye giren kontrol sınıfıdır. Giren User’ın yetkili olup olmadığına burada bakılır.
Geldik bir makalenin daha sonuna. Bu makalede uçtan uca katmanlı bir mimari kullanılarak, yetki kontrolünün method ve client bazında nasıl yapıldığını hep beraber inceledik. Umarım işinize yarar.
Yeni bir makalede görüşmek üzere, hepinize hoşçakalın.
Source Code: https://github.com/borakasmer/.Net-Core-UserRoles
Emeğinize sağlık hocam teşekkür ederiz
İşinize yaradıysa, ne mutlu bana :)
Harika bir makale daha emeğinize sağlık.
Emeğimize sağlık hocam. IRepository Scoped ve IRoleService’ Transient olarak tanımlamanızın bir sebebi var mı? İkisi de Singleton problem oluşturacak bir durum var mı?
Teşekkürler Safa. Repository katmanı Scoped olarak tanımlanır ise, using{}’li bir kullanım halini almış olur. Bu da yüksek trafikde performans artışına sebebiyet verir. Çünkü sadece kullanıldıkları zaman yaratılıp kaldırılırlar. Yüksek trafikte 20K kişinin siteye gelmesi ile Transient tanımında 20K Repository katmanı, session ayakta olduğu sürece yer kaplayacak ve bu da ciddi bir maliyet doğuracaktır. IRoleService için de yine aynı durum geçerlidir.
Emeğimize sağlık hocam. IRepository Scoped ve IRoleService’ Transient olarak tanımlamanızın bir sebebi var mı? İkisi de Singleton olsa problem oluşturacak bir durum olur mu?
Teşekkürler. Repository katmanı Scoped olarak tanımlanır ise, using{}’li bir kullanım halini almış olur. Bu da yüksek trafikde performans artışına sebebiyet verir. Çünkü sadece kullanıldıkları zaman yaratılıp kaldırılırlar. Yüksek trafikte 20K kişinin siteye gelmesi ile Transient tanımında 20K Repository katmanı, session ayakta olduğu sürece yer kaplayacak ve bu da ciddi bir maliyet doğuracaktır. IRoleService için de yine aynı durum geçerlidir.
Teşekkür ederiz çok güzel bir kaynak olmuş. Favorilere ekliyorum :)
Ben teşekkür ederim..
Eline emeğine sağlık hocam. Değişik ve değerli bir bakış açısı için uğraş vermişsin teşekkürler. Ancak anlayamadığım bir konu var. Fazlaca rol olan sistemlerde kontrol nasıl olacak? Belki verdiğim örnek yanlış ama sorumun mantığını anlatmak adına; Diyelim ki rol toplamı 18. Bunun 12+6, 14+4, 10+ 8, 8+6+4 ya da sadece 18 olabilme gibi olasılıkları var. Bu ayrımı nasıl yapıyoruz?
Selam Şakir,
Öncelikle güzel yorumun için teşekkürler. Bitwise öteleme, senin anladığın şekilde çalışmıyor. Şurada detaylıca anlattım. Tek olumsuz durum 2^63 yani 63 item bir grup altında tanımlayabilirsiniz.
Video’yu izledikten sonra halen aklında soruların olur ise, buradan bana sorabilirsin.
Merhaba Hocam,
Makale için teşekkürler, çok güzel bir makale olmuş. Benim tam olarak anlayamadığım konu şu, biz 63 tane yetkiyi(action) toplam bütün controller için mi verebiliyoruz ? Yoksa ben HomeController için 63 tane action tanımlayabilirim. PersonController için de 63 tane ayrı action tanımlayabilir miyim ?
Selam Mustafa,
Öncelikle teşekkürler. Her bir controller için 63 tane yetki verebilirsin.
Yani HomeController için 63 Action yetkisi, PersonController için 63 Action yetkisi. RoleGroup ==> Controller, Role ==> Action.
İyi çalışmalar
Teşekkürler hocam,
Tam da user management üzerinde çalışırken çok güzel bir makale oldu. :)
Teşekkür ederim Mustafa..
Teşekkürler. Gerçekten çok büyük bir derdi çözdü. Ayrıca sizin yazıların şöyle bir faydası var . Bir konuyu öğrenirken başka bir konuda nasıl daha iyi yapılacağını gösteriyor. Örn: response meselesinde interface tanımlamak.
Yalnız protected virtual DbSet Entities => _entities ?? (_entities = _context.Set()); bunun mantığı nedir anlayamadım.
Elinize sağlık.
Hocam selamlar,
Öncelikle bu güzel makale için teşekkürler. Emeğinize sağlık.
Ben ServiceResponse olayı ile ilgili bir soru sormak istiyorum. ServiceResponse içersinde HttpStatus kodlarını da handle etmek gerekmez mi? Mesela IsSuccessful false olan bir operasyon için Http200 dönüyor olması tezat olmaz mı?
Selamlar,
Olur. Zaten status kodlara hep bakmak gerekir.Burdaki IsSuccessfu işleri biraz daha hızlandırmak için konulmuştur..
İyi çalışmalar.
Merhaba hocam,
Emeğinize sağlık hocam. Çok yararlı bir kaynak.
Bir projede gördüm IActionDescriptorCollectionProvider sınıfını kullanıp, Contolleri otomotik yakalayabilirmiyiz.
Bununla birlikte veritabanında sadece Controller isimlerini tutup yetkilendirme yapmak istiyorum ama bir kaynak bulamadım. Bi eğitim çekermisiniz. Yada bununla ilgili bir döküman varmıdır.
iyi çalışmalar dilerim.