承接上篇,建立了一個網頁閒置偵測的機制
這次要談的內容是 authorization
(授權)
authentication
(認證) 跟 authorization
(授權),關注的地方是不同的
authentication 關注在身分的核對,旨在確認對方是會員
而 authorization 關注在這個user他有什麼樣的權利,可以訪問哪些功能
Authorization 的實作有相當多的類型,光是 .net 原生就提供了許多種設定方式 [1]
甚至是一些官方範例 [2]
網路上還有允許動態調整權限的範例 [3]
一個 User 自身可以擁有多種 role,若某A擁有 admin
role
他或許可以停權別人的帳號
[Authorize(Roles = "admin")]
public void UpdateUserAccount(User user){
// do something
}
有的時候,我們可能會需要多個 role 才能存取一個 function
[Authorize(Roles = "admin_read, admin_write")]
public void UpdateUserAccount(User user){
// do something
}
那如果 role 的某些組合很常出現,那就必須要一直把他們寫出來
這時就可以考慮用 policy,直接設定admin policy
含有admin_read
admin_write
兩個權限
[Authorize(Policy = "admin")]
public void UpdateUserAccount(User user){
// do something
}
原生的 authorization 機制是滿完善的,但要使用他們會造成系統開發的一些限制
必須使用.net提供的DB結構,否則許多地方不僅要重造輪子,甚至會變成魔改
為了運用.net提供的DB結構,會變成可能得要使用 Entity Framework,不然與DB聯動的部分還是得要自己重造輪子
為了好好的運用前兩項的限制
,必須要讓開發人員花時間去了解.net提供的完整
認證授權機制
如果系統不夠大,做這樣的付出,CP值可能不是很高
如果運用了 .net 提供的機制,要自己做些客製化,難度會高出許多,還不如自己造輪子
如果要純SPA的話,.net提供的範例都是MVC,即便是SPA的範例,在認證這塊也是用MVC實作
所以這會導致無法做到純SPA,要純SPA,要改造的地方會非常的多
當然必須要說的是,原生的機制非常的強大與完整,但同時內容太多了
如果只是要做一些簡單的認證授權機制,反而是有些不方便的
綜上所述,這邊主要參考 stackoverflow 上的討論[4]
使用客製化的 Authorization Action Filter
與簡單的 Table 設計
搭配 Entity Framework Core,Code First 策略來實作授權機制
並使用MS SQL Server來存放DB,及SSMS來看DB中的資料
讓 weather api 可以被擁有 User
權限的用戶存取
因此就算是擁有 Admin
權限的用戶,只要他沒有 User
的權限
當他存取 weather api 時,依然要 401 Unauthorize
以下影片為上述的示意
Entity Framework 是一種透過 ORM(Object Relational Mapping) 來存取DB的方式
只要建立一些Class,描述清楚資料間的關聯,就可以直接用EF來創建/調整DB,並存取DB的資料
會比自己手動寫SQL在程式裡安全,也會比自己寫 stored procedure 還要快速
當然 EF 不是萬能的,有些時候要透過 EF 來達成複雜的 SQL 指令可能會有些難度
因此簡單的 CRUD 可以使用 EF 來達到
而當要處理複雜又講求效率的 SQL 時,就可以再使用其他方式達成(如stored procedure)
想了解更多 EF 的東西的話,可以參考看看這些文章[5][6][7][8]
首先,架構上,這次將會把資料寫死在DB
,將不再把資料寫死在程式碼中
因此會在專案中設計好DB的Table,再使用EF(Entity Framework)直接更新/建立DB
並透過EF來存取DB中的資料
接著會在之前的基礎上進行認證
部分的改造,改為從DB讀資料,並且更改為信箱
,密碼
認證
接著實作授權的機制
Table 上主要要建2+1個 Table
User: 用來存User的主要資訊,像是姓名、Email、Password...
比較特別的是,密碼存的是經過Hash處理的字串,以防資料庫若不小心外洩,密碼會比較
不容易外洩
而密碼的運算,這邊主要參考自 stackoverflow 上的這邊 [9]
User Role: 用來存目前有哪些 Role
MapUserRole: 由於User
跟User Role
是Many-to-Many的關聯,因此需要這張Table紀錄這個關係
官方在關聯的說明上還算講的滿好懂得,有興趣可以多參考參考[8:1]
以下是這邊建立Table的一些說明
User
public class User
{
// 設定PK,並且讓Id可以auto increment
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
public string Name { get; set; }
// 利用 Data Anotation 標注是 Email
// 這樣還能自動進行欄位的後端驗證
[DataType(DataType.EmailAddress)]
public string Email { get; set; }
public string PasswordHash { get; set; }
public AccountStatus AccountStatus { get; set; }
// 以下兩個欄位是為了設定多對多的關係
public ICollection<UserRole> UserRoles { get; set; }
[JsonIgnore]
public List<MapUserRole> MapUserRoles { get; set; }
public User()
{
}
public User(string email, string password)
{
this.Email = email;
this.PasswordHash = SecurePasswordHasher.Hash(password);
this.AccountStatus = AccountStatus.Uncheck;
}
}
User Role
public class UserRole
{
[Key]
public int Id { get; set; }
/// <summary>
/// for matching program code
/// </summary>
public string CodeName { get; set; }
public string DisplayName { get; set; }
// 透過設置 JsonIgnore,避免序列化資料到前端時產生循環參考
[JsonIgnore]
public ICollection<User> Users { get; set; }
[JsonIgnore]
public List<MapUserRole> MapUserRoles { get; set; }
}
MapUserRole
public class MapUserRole
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
// 設置多對多關係
public int UserId { get; set; }
public int UserRolesId { get; set; }
// 透過下面的語法設置ORM的多對多關係
public User User { get; set; }
public UserRole UserRoles { get; set; }
}
DB Context
public class ApplicationDbContext : IdentityDbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
// 將3張Table註冊起來,讓EF知道要建立的就是下面3張Table
public DbSet<User> User { get; set; }
public DbSet<UserRole> UserRole { get; set; }
public DbSet<MapUserRole> MapUserRoles { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 必須要加這行執行預設的行為,否則會無法正常執行指令
base.OnModelCreating(modelBuilder);
// 透過 fluent api 將 Email 設置為 unique
modelBuilder.Entity<User>()
.HasIndex(u => u.Email)
.IsUnique();
// 透過 fluent api 與以下語法建立多對多關係
modelBuilder.Entity<User>()
.HasMany(p => p.UserRoles)
.WithMany(p => p.Users)
.UsingEntity<MapUserRole>(
j => j
.HasOne(pt => pt.UserRoles)
.WithMany(t => t.MapUserRoles)
.HasForeignKey(pt => pt.UserRolesId),
j => j
.HasOne(pt => pt.User)
.WithMany(p => p.MapUserRoles)
.HasForeignKey(pt => pt.UserId),
j =>
{
j.HasKey(t => new { t.UserId, t.UserRolesId });
});
// 設置初始資料,後續會提到
SeedData(modelBuilder);
}
}
有的系統在初始設計時,就會將一些參數初始化設置進DB當中,在 EF 裡也是允許做這件事的
手段有很多種,一種是建立/更新DB時,一併把資料設置上去,一種是啟動系統時建立
這邊採用前者來設置初始資料
protected void SeedData(ModelBuilder modelBuilder)
{
// add admin, user
modelBuilder
.Entity<User>()
.HasData(new User("admin@auth.com", "@Dmin")
{
Id = 1,
Name = "Admin",
AccountStatus = AccountStatus.Approved
});
modelBuilder
.Entity<User>()
.HasData(new User("user@auth.com", "user1234")
{
Id = 2,
Name = "User",
AccountStatus = AccountStatus.Approved
});
// add role
modelBuilder
.Entity<UserRole>()
.HasData(new UserRole
{
Id = (int)Models.UserRoles.Admin,
CodeName = Models.UserRoles.Admin.ToString(),
DisplayName = Models.UserRoles.Admin.ToString(),
});
modelBuilder
.Entity<DBModels.UserRole>()
.HasData(new DBModels.UserRole
{
Id = (int)Models.UserRoles.User,
CodeName = Models.UserRoles.User.ToString(),
DisplayName = Models.UserRoles.User.ToString()
});
// update many-to-many relation
// add role of user
modelBuilder
.Entity<MapUserRole>()
.HasData(new MapUserRole
{
UserId = 1,
UserRolesId = (int)Models.UserRoles.Admin
});
modelBuilder
.Entity<MapUserRole>()
.HasData(new MapUserRole
{
UserId = 2,
UserRolesId = (int)Models.UserRoles.User
});
}
當建立好用來對應Table的物件後,就可以透過 dotnet-ef
下指令來更新/建立DB了
而要使用該指令前,會需要先使用 powershell/bash 來安裝該指令
dotnet tool install --global dotnet-ef
接著當第一次建立Table,或後續架構更改,都可以用以下指令來更新DB
dotnet ef migrations add InitialCreate
dotnet ef database update
當Table對應的Class有進行異動時,就可以使用以上指令來進行更新
若希望留下歷程,可以把 InitialCreate 改為其他的名字
這樣就可以在 Migrations 資料夾留下異動的紀錄
而當DB有資料時,Table卻異動了,也可以使用指令來進行更新
有些情況他會幫你處理資料的異動,但有些他判斷不行的,也會有錯誤訊息出來
當在 API 的 Controller進行異動時,若在function裡面處理資料對DB的動作
會製造大量重複的邏輯
會讓 Controller 中的邏輯變得複雜,可讀性降低
因此可以將一些對DB的邏輯包裝起來,透過 DI 的機制注入到 Controller 中使用
// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
// 將 DB Context 注入
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection")));
// 將包裝起來的邏輯注入
services.AddScoped<IUserAuthHandler<User, int>, UserAuthHandler>();
}
AddScoped
是代表注入的生命週期運作模式,詳情可以參考這邊[10]
實作的步驟大致為
建立 class 實作 IAuthorizationFilter
從 session 中讀出使用者的 id
利用 id 查出該 user 擁有哪些 role
比對 user 的 role 跟 function 上設定的 role
當比對OK才代表該 user 有被授權存取
public class AuthorizeActionFilter : IAuthorizationFilter
{
private readonly UserRoles _userRole;
protected readonly IUserAuthHandler<User,int> _authHandler;
public AuthorizeActionFilter(UserRoles userRole, IUserAuthHandler<User, int> authHandler)
{
this._userRole = userRole;
this._authHandler = authHandler;
}
public void OnAuthorization(AuthorizationFilterContext context)
{
// 從 session 讀取 id
int? id = context.HttpContext.User.GetUserId();
if (id == null)
{
context.Result = new UnauthorizedResult();
return;
}
// 讀取 user 擁有的 role
IEnumerable<UserRole> roles = this._authHandler.FindById((int)id)?.UserRoles;
// 進行 role 的比對
bool isAuthorized = roles.Any(x => x.CodeName.ToLower() == this._userRole.ToString().ToLower() );
if (!isAuthorized)
{
context.Result = new UnauthorizedResult();
}
}
}
由於之前測試認證是使用 Email,並且寫死測資
這邊改寫成以 Email、密碼的方式來進行登入登出
並將組建的名稱改為 SignIn.vue
Code請參考這邊
由於這次增加了 User Role,在拿取 User 的資料上,格式也進行了異動
對應會更改到的是 GetUserInfo
這支API,以及 store 中儲存的格式
這邊透過 EF 建立了一個小小的會員認證、授權機制
而由於 role 被存在了 DB,所以其實這些設定
都是可以很動態的進行更改的
只要建個後臺機制,這樣就可以讓擁有 Admin Role 的人直接使用網站來調整 User 的權限了
完整的Code在此 歡迎參考看看啦