.NET Core Identity
在開發新專案時,身份驗證和授權通常為第一個實做的功能,在 dotnet 生態系中微軟官方會推薦你使用 ASP.NET Core Identity 來實踐 大部分的文章只會要你使用 Visual Studio 直接勾選使用樣板,或是直接給你一段命令直接產生專案
dotnet new webapp --auth Individual -uld -o WebApp1
就可以產生一個能夠進行註冊與登入的簡易網站,這樣雖然可以快速產生一個可以用的專案, 假如我想要在 User 表加入自定義的欄位,或者客製化授權方面的邏輯,那就必須調整一下樣板
首先我們看看原始沒有包含授權功能的樣板 webapp
建立的專案(WebApp2),與我們剛剛建立有包含授權功能的 WebApp1
專案進行比較,觀察 dotnet 額外新增了什麼程式碼
dotnet new webapp -o WebApp2
發現額外參考了幾個 package
- Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore // 顯示與資料庫遷移相關的錯誤,開發時期使用
- Microsoft.AspNetCore.Identity.EntityFrameworkCore // 內部包含 IdentityDbContext 用來存取 Identity 資料表
- Microsoft.AspNetCore.Identity.UI // 預設 Identity cshtml 登入、登出頁面
- Microsoft.EntityFrameworkCore.SQLServer //EFCore SQLServer Provider
- Microsoft.EntityFrameworkCore.Tools //更新和更改資料庫架構與管理遷移腳本,開發時期使用
並且生成了專案獨立的 DbContext ApplicationDbContext
內部繼承了 IdentityDbContext
以及產生資料庫 Migrations
頁面新增 _LoginPartial.cshtml 並添加到目前 _Layout 內,右上角會根據登入狀態顯示登入登出按鈕或是帳號管理。
最後在 Program.cs
中多了一段與 Identity 相關的程式碼
builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddEntityFrameworkStores<ApplicationDbContext>();
從上一段整理的資訊能夠得知,使用系統預設樣板就會直接依賴 EFCore
與 Razor
這兩個主要模組,那就會產生幾個疑問
- 如果今天專案不想使用 EFCore 只想使用 Dapper 或者乾脆不使用 ORM 有沒有辦法實現?
- 如果團隊沒有人會 Razor 語法或者不想使用 Razor 那該怎麼辦?
如果有這類的需求那就只能客製化自己的 ASP.NET Core Identity 與搭配額外的 Controller 提供資料給前端團隊對接 這邊直接建立一個乾淨的 webapi 專案來進行開發並添加新的 Package
dotnet new webapi -o IdentityDapper
dotnet add package Microsoft.Extensions.Identity.Stores --version 9.0.8
由於 Microsoft.AspNetCore.Identity.EntityFrameworkCore
這個 package 是微軟官方唯一提供的 Provider,
所以你也可以自行去安裝第三方的 Provider 省去自行開發的時間,這個範例選擇自行開發。
仔細看過 Microsoft.AspNetCore.Identity.EntityFrameworkCore
的相關依賴後
得知這個 package 是建立在 Microsoft.Extensions.Identity.Stores
這個 package 之上,所以我們直接將它添加道專案即可。
接下來在 Program.cs
新增以下程式碼
builder.Services.AddIdentityCore<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true);
這邊有三個類似的擴充方法經常出現 AddIdentityCore<TUser>
,AddDefaultIdentity<TUser>
,AddIdentity<TUser, TRole>
AddIdentityCore
只會註冊核心的功能,如果需要高度客製化可以安裝它並案自己的需求註冊功能。
AddDefaultIdentity
內部也會使用 AddIdentityCore 擴充方法並且額外呼叫 AddDefaultUI 與 AddDefaultTokenProviders 擴充方法,註冊
UI 與一些常用的 TokenProviders。
AddIdentity
會直接註冊大部分實用的服務例如 cookie 與 2fa 與 role
# AddIdentity
public static IdentityBuilder AddIdentity<TUser, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TRole>(
this IServiceCollection services,
Action<IdentityOptions> setupAction)
where TUser : class
where TRole : class
{
// Services used by identity
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
})
.AddCookie(IdentityConstants.ApplicationScheme, o =>
{
o.LoginPath = new PathString("/Account/Login");
o.Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync
};
})
.AddCookie(IdentityConstants.ExternalScheme, o =>
{
o.Cookie.Name = IdentityConstants.ExternalScheme;
o.ExpireTimeSpan = TimeSpan.FromMinutes(5);
})
.AddCookie(IdentityConstants.TwoFactorRememberMeScheme, o =>
{
o.Cookie.Name = IdentityConstants.TwoFactorRememberMeScheme;
o.Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = SecurityStampValidator.ValidateAsync<ITwoFactorSecurityStampValidator>
};
})
.AddCookie(IdentityConstants.TwoFactorUserIdScheme, o =>
{
o.Cookie.Name = IdentityConstants.TwoFactorUserIdScheme;
o.Events = new CookieAuthenticationEvents
{
OnRedirectToReturnUrl = _ => Task.CompletedTask
};
o.ExpireTimeSpan = TimeSpan.FromMinutes(5);
});
// Hosting doesn't add IHttpContextAccessor by default
services.AddHttpContextAccessor();
// Identity services
services.TryAddScoped<IUserValidator<TUser>, UserValidator<TUser>>();
services.TryAddScoped<IPasswordValidator<TUser>, PasswordValidator<TUser>>();
services.TryAddScoped<IPasswordHasher<TUser>, PasswordHasher<TUser>>();
services.TryAddScoped<ILookupNormalizer, UpperInvariantLookupNormalizer>();
services.TryAddScoped<IRoleValidator<TRole>, RoleValidator<TRole>>();
// No interface for the error describer so we can add errors without rev'ing the interface
services.TryAddScoped<IdentityErrorDescriber>();
services.TryAddScoped<ISecurityStampValidator, SecurityStampValidator<TUser>>();
services.TryAddScoped<ITwoFactorSecurityStampValidator, TwoFactorSecurityStampValidator<TUser>>();
services.TryAddScoped<IUserClaimsPrincipalFactory<TUser>, UserClaimsPrincipalFactory<TUser, TRole>>();
services.TryAddScoped<IUserConfirmation<TUser>, DefaultUserConfirmation<TUser>>();
services.TryAddScoped<UserManager<TUser>>();
services.TryAddScoped<SignInManager<TUser>>();
services.TryAddScoped<RoleManager<TRole>>();
if (setupAction != null)
{
services.Configure(setupAction);
}
return new IdentityBuilder(typeof(TUser), typeof(TRole), services);
}
#IdentityServiceCollectionExtensions
public static IdentityBuilder AddIdentityCore<TUser>(this IServiceCollection services, Action<IdentityOptions> setupAction)
where TUser : class
{
// Services identity depends on
services.AddOptions().AddLogging();
// Services used by identity
services.TryAddScoped<IUserValidator<TUser>, UserValidator<TUser>>();
services.TryAddScoped<IPasswordValidator<TUser>, PasswordValidator<TUser>>();
services.TryAddScoped<IPasswordHasher<TUser>, PasswordHasher<TUser>>();
services.TryAddScoped<ILookupNormalizer, UpperInvariantLookupNormalizer>();
services.TryAddScoped<IUserConfirmation<TUser>, DefaultUserConfirmation<TUser>>();
// No interface for the error describer so we can add errors without rev'ing the interface
services.TryAddScoped<IdentityErrorDescriber>();
services.TryAddScoped<IUserClaimsPrincipalFactory<TUser>, UserClaimsPrincipalFactory<TUser>>();
services.TryAddScoped<UserManager<TUser>>();
if (setupAction != null)
{
services.Configure(setupAction);
}
return new IdentityBuilder(typeof(TUser), services);
}
比較兩段程式碼會發現 AddIdentityCore 有註冊的方法在 AddIdentity 中都有註冊並且多了許多功能,可以按照自己的需求做選擇 如果兩個都不滿意的話可以寫一個自己的方法並且回傳 IdentityBuilder 也可以達成同樣的需求。
回到我們的 API 專案,這邊如果直接將專案運行起來會報錯,根據報錯內容可以得知 .NET Core Identity 嘗試建立 UserManager<IdentityUser>
時,
發現 DI 容器裡並沒有在註冊 IUserStore<IdentityUser>
相關服務。
我們可以回到 WebApp1 專案看一下為什麼預設的專案不會有這個問題
builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddEntityFrameworkStores<ApplicationDbContext>();
發現下方還有呼叫另一個方法 AddEntityFrameworkStores
Github
底部有一段程式碼會透過 IdentityDbContext(EFCore) 註冊 <IUserStore, UserOnlyStore>,所以專案使用 EFCore Provider 的話就不會碰到此問題
services.TryAddScoped(typeof(IUserStore<>).MakeGenericType(userType), userStoreType);
由於我們的專案並沒有使用 EFCore,我們就需要額外實做 IUserStore 並且註冊到 DI 內部,先參考一下微軟官方的文檔 Customize the user store
得知 UserStore
是負責處理程式到資料庫的這一段邏輯,所以我們需要建立一個類型並且實做 IUserStore<TUser>
這個界面(底下有許多可選的界面),
這樣 UserManager
才有辦法知道怎麼存取資料庫中的資料。
根據上一段的內容我們得知 AddEntityFrameworkStores
實際上會註冊 UserStore
或者 UserOnlyStore
,可以參考微軟是怎麼實現的 UserOnlyStore
了解背後邏輯後我們可以來實做看看自己的 UserStore,先建立一個 CustomUserStore
並且準備實做 IUserStore<IdentityUser>
介面的所有方法
public class CustomUserStore : IUserStore<IdentityUser>
{
public Task<IdentityResult> CreateAsync(IdentityUser user, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<IdentityUser?> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
...
}
接下來安裝 Npgsql
和 Dapper
Package
dotnet add package Npgsql
dotnet add package Dapper
建立一個新的 Database IdentityDapper
並且建立新的 Schema 與 Table
CREATE SCHEMA dbo;
CREATE TABLE dbo.AspNetUsers (
Id text NOT NULL,
UserName character varying(256) NULL,
NormalizedUserName character varying(256) NULL,
Email character varying(256) NULL,
NormalizedEmail character varying(256) NULL,
EmailConfirmed boolean NOT NULL,
PasswordHash text NULL,
SecurityStamp text NULL,
ConcurrencyStamp text NULL,
PhoneNumber text NULL,
PhoneNumberConfirmed boolean NOT NULL,
TwoFactorEnabled boolean NOT NULL,
LockoutEnd timestamp with time zone NULL,
LockoutEnabled boolean NOT NULL,
AccessFailedCount integer NOT NULL,
CONSTRAINT PK_AspNetUsers PRIMARY KEY (Id)
);
CREATE INDEX EmailIndex ON dbo.AspNetUsers (NormalizedEmail);
CREATE UNIQUE INDEX UserNameIndex ON dbo.AspNetUsers (NormalizedUserName);
完成之後回到 CustomUserStore
準備先實做 CreateAsync
與 FindByNameAsync
方法
public async Task<IdentityResult> CreateAsync(IdentityUser user, CancellationToken cancellationToken)
{
var connString = _configuration.GetSection("ConnectionStrings").GetValue<string>("DefaultConnection");
await using var conn = new NpgsqlConnection(connString);
await conn.OpenAsync();
int rows;
await using (var sqlConnection = conn) {
var command = $"INSERT INTO dbo.AspNetUsers " +
"VALUES (@Id, @UserName, @NormalizedUserName, @Email, @NormalizedEmail, @EmailConfirmed, @PasswordHash, @SecurityStamp, @ConcurrencyStamp, " +
"@PhoneNumber, @PhoneNumberConfirmed, @TwoFactorEnabled, @LockoutEnd, @LockoutEnabled, @AccessFailedCount);";
rows = await sqlConnection.ExecuteAsync(command, new {
user.Id,
user.UserName,
user.NormalizedUserName,
user.Email,
user.NormalizedEmail,
user.EmailConfirmed,
user.PasswordHash,
user.SecurityStamp,
user.ConcurrencyStamp,
user.PhoneNumber,
user.PhoneNumberConfirmed,
user.TwoFactorEnabled,
user.LockoutEnd,
user.LockoutEnabled,
user.AccessFailedCount
});
}
return rows == 1 ? IdentityResult.Success : IdentityResult.Failed(new IdentityError {
Code = nameof(CreateAsync),
Description = $"Insert User Error"
});
}
public async Task<IdentityUser?> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken)
{
var connString = _configuration.GetSection("ConnectionStrings").GetValue<string>("DefaultConnection");
await using var conn = new NpgsqlConnection(connString);
await conn.OpenAsync();
await using (var sqlConnection = conn)
{
var command = "SELECT * " +
"FROM dbo.AspNetUsers " +
"WHERE NormalizedUserName = @NormalizedUserName;";
return await sqlConnection.QuerySingleOrDefaultAsync<IdentityUser>(command, new
{
NormalizedUserName = normalizedUserName
});
}
}
回到 Program.cs
將 CustomUserStore
註冊到 DI 內
可以使用擴充方法 AddUserStore
也可以自行註冊
builder.Services.AddIdentityCore<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddUserStore<CustomUserStore>();
這樣就完成了,我們可以建立一個 Controller 來方便測試
[ApiController]
[Route("[controller]")]
public class AccountController : ControllerBase
{
private readonly IUserStore<IdentityUser> _userStore;
public AccountController(IUserStore<IdentityUser> userStore)
{
_userStore = userStore;
}
[HttpPost(Name = "CreateUser")]
public async Task<IdentityResult> CreateUser()
{
var userName = "User1";
var identityUser = new IdentityUser(userName)
{
NormalizedUserName = userName.ToUpper()
};
return await _userStore.CreateAsync(identityUser,CancellationToken.None);
}
[HttpGet(Name = "GetUser")]
public async Task<IdentityUser?> GetUser()
{
var userName = "User1";
return await _userStore.FindByNameAsync(userName.ToUpper(),CancellationToken.None);
}
}
最後完整的 Program.cs
using IdentityDapper;
using Microsoft.AspNetCore.Identity;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
builder.Services.AddIdentityCore<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddUserStore<CustomUserStore>();
builder.Services.AddControllers();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
完成後先 Post https://localhost:7057/Account/CreateUser
建立新 User
在 Get https://localhost:7057/Account/GetUser
讀取,有回傳就代表測試成功了
{
"id": "45d33c72-012f-4f54-b163-2645b5df9f6c",
"userName": "User1",
"normalizedUserName": "USER1",
"email": null,
"normalizedEmail": null,
"emailConfirmed": false,
"passwordHash": null,
"securityStamp": "738435cc-9b65-4b9f-a327-af3085a617d7",
"concurrencyStamp": "3b80fea7-416d-4fab-94ee-33cd4ce93ed9",
"phoneNumber": null,
"phoneNumberConfirmed": false,
"twoFactorEnabled": false,
"lockoutEnd": null,
"lockoutEnabled": false,
"accessFailedCount": 0
}
Summary
今天初步了解了 .NET Core Identity 的基礎,不過直接使用 UserStore 的方法雖然可行,但是看起來好像沒什麼意義,
,根本不需要用到 .NET Core Identity,唯一的好處就是實做 IUserStore
介面可以不用自己想方法名稱。
這是因為實務上不該直接呼叫 IUserStore
而是該使用的是各種的 Manager 方法,.NET Core Identity 的高階方法都是放在
UserManager<IdentityUser>
、SignInManager<IdentityUser>
這種 Manager 裡面,例如密碼驗證、Claims、角色管理等等。
所以直接使用 IUserStore,等於你得自己額外處理所有商業邏輯。
例如要註冊一個會員需要先檢查資料庫有沒有同樣名稱的會員,確認沒有才可以插入到資料庫內或者會員登入之前需要經過許多商業邏輯的檢查 這些邏輯已經都被包在各種的 Manager 方法內部了,之後的文章會在詳細探討。