ASP.NET Core 的認證與授權已經不是什麼新鮮事了,微軟官方的文件對於如何在 ASP.NET Core 中實現認證與授權有著非常詳細深入的介紹。但有時候在開發過程中,我們也往往會感覺無從下手,或者由於一開始沒有進行認證授權機制的設計與規劃,使得後期出現一些混亂的情況。這裡我就嘗試結合一個實際的例子,從0到1來介紹 ASP.NET Core 中如何實現自己的認證與授權機制。
當我們使用 Visual Studio 自帶的 ASP.NET Core Web API 專案模板新建一個專案的時候,Visual Studio 會問我們是否需要啟用認證機制,如果你選擇了啟用,那麼Visual Studio 會在專案建立的時候,加入一些輔助依賴和一些輔助類,比如加入對Entity Framework 以及ASP.NET Identity 的依賴,以幫助你實現基於 Entity Framework 和 ASP.NET Identity 的身份認證。如果你還沒有了解過 ASP.NET Core 的認證與授權的一些基礎內容,那麼當你開啟這個由 Visual Studio 自動建立的專案的時候,肯定會一頭霧水,不知從何開始,你甚至會懷疑自動建立的專案中,真的是所有的類或者方法都是必須的嗎?
所以,為了讓本文更加簡單易懂,我們還是選擇不啟用身份認證,直接建立一個最簡單的 ASP.NET Core Web API 應用程式,以便後續的介紹。
新建一個 ASP.NET Core Web API 應用程式,這裡我是在 Linux 下使用 JetBrains Rider 新建的專案,也可以使用標準的 Visual Studio 或者 VSCode 來建立專案。建立完成後,執行程式,然後使用瀏覽器訪問 /WeatherForecast 端點,就可以獲得一組隨機生成的天氣及溫度資料的陣列。你也可以使用下面的 curl 命令來訪問這個 API:
1curl -X GET "http://localhost:5000/WeatherForecast" -H "accept: text/plain"
現在讓我們在 WeatherForecastController 的 Get 方法上設定一個斷點,重新啟動程式,仍然傳送上述請求以命中斷點,此時我們比較關心 User 物件的狀態,開啟監視器檢視 User 物件的屬性,發現它的 IsAuthenticated 屬性為 false:
在很多情況下,我們可能並不需要在 Controller 的方法中獲取認證使用者的資訊,因此也從來不會關注 User 物件是否真的處於已被認證的狀態。但是當 API 需要根據使用者的某些資訊來執行一些特殊邏輯時,我們就需要在這裡讓 User 的認證資訊處於一種合理的狀態:它是已被認證的,並且包含 API 所需的資訊。這就是本文所要討論的 ASP.NET Core 的認證與授權。
認證
應用程式對於使用者的身份認定包含兩部分:認證和授權。認證是指當前使用者是否是系統的合法使用者,而授權則是指定合法使用者對於哪些系統資源具有怎樣的訪問許可權。我們先來看如何實現認證。
在此,我們單說由 ASP.NET Core 應用程式本身實現的認證,不討論具有統一 Identity Provider 完成身份認證的情況(比如單點登入),這樣的話就能夠更加清晰地瞭解 ASP.NET Core 本身的認證機制。接下來,我們嘗試在 ASP.NET Core 應用程式上,實現 Basic 認證。
Basic 認證需要將使用者的認證資訊附屬在 HTTP 請求的Authorization 的頭(Header)上,認證資訊是一串由使用者名稱和密碼通過 BASE64 編碼後所產生的字串,例如,當你採用 Basic認證,並使用daxnet和password 作為訪問 WeatherForecast API 的使用者名稱和密碼時,你可能需要使用下面的命令列來呼叫WeatherForecast:
1curl -X GET "http://localhost:5000/WeatherForecast" -H "accept: text/plain" -H "Authorization: Basic ZGF4bmV0OnBhc3N3b3Jk"
在 ASP.NET Core Web API 中,當應用程式接收到上述請求後,就會從 Request的 Header 裡讀取 Authorization 的資訊,然後 BASE64 解碼得到使用者名稱和密碼,然後訪問資料庫來確認所提供的使用者名稱和密碼是否合法,以判斷認證是否成功。這部分工作通常可以採用 ASP.NET Core Identity 框架來實現,不過在這裡,為了能夠更加清晰地瞭解認證的整個過程,我們選擇自己動手來實現。
首先,我們定義一個 User 物件,並且預先設計好幾個使用者,以便模擬儲存使用者資訊的資料庫,這個 User 物件的程式碼如下:
public class User
{
public string UserName { get; set; }
public string Password { get; set; }
public IEnumerable<string> Roles { get; set; }
public int Age { get; set; }
public override string ToString() => UserName;
public static readonly User[] AllUsers = {
new User
{
UserName = "daxnet", Password = "password", Age = 16, Roles = new[] { "admin", "super_admin" }
},
new User
{
UserName = "admin", Password = "admin", Age = 29, Roles = new[] { "admin" }
}
};
}
該 User 物件包括使用者名稱、密碼以及它的角色名稱,不過暫時我們不需要關心角色資訊。User 物件還包含一個靜態欄位,我們將它作為使用者資訊資料庫來使用。
接下來,在應用程式中新增一個 AuthenticationHandler,用來獲取 Request Header 中的使用者資訊,並對使用者資訊進行驗證,程式碼如下:
public class BasicAuthenticationHandler : AuthenticationHandler<BasicAuthenticationSchemeOptions>
{
public BasicAuthenticationHandler(
IOptionsMonitor<BasicAuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.ContainsKey("Authorization"))
{
return Task.FromResult(AuthenticateResult.Fail("Authorization header is not specified."));
}
var authHeader = Request.Headers["Authorization"].ToString();
if (!authHeader.StartsWith("Basic "))
{
return Task.FromResult(
AuthenticateResult.Fail("Authorization header value is not in a correct format"));
}
var base64EncodedValue = authHeader["Basic ".Length..];
var userNamePassword = Encoding.UTF8.GetString(Convert.FromBase64String(base64EncodedValue));
var userName = userNamePassword.Split(':')[0];
var password = userNamePassword.Split(':')[1];
var user = User.AllUsers.FirstOrDefault(u => u.UserName == userName && u.Password == password);
if (user == null)
{
return Task.FromResult(AuthenticateResult.Fail("Invalid username or password."));
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.UserName),
new Claim(ClaimTypes.Role, string.Join(',', user.Roles)),
new Claim(ClaimTypes.UserData, user.Age.ToString())
};
var claimsPrincipal =
new ClaimsPrincipal(new ClaimsIdentity(
claims,
"Basic",
ClaimTypes.NameIdentifier, ClaimTypes.Role));
var ticket = new AuthenticationTicket(claimsPrincipal, new AuthenticationProperties
{
IsPersistent = false
}, "Basic");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
在上面的 HandleAuthenticateAsync 程式碼中,首先對 Request Header 進行合法性校驗,比如是否包含 Authorization 的 Header,以及 Authorization Header 的值是否合法,然後,將 Authorization Header 的值解析出來,通過 Base64 解碼後得到使用者名稱和密碼,與使用者資訊資料庫裡的記錄進行匹配,找到匹配的使用者。接下來,基於找到的使用者物件,建立 ClaimsPrincipal,並基於 ClaimsPrincipal 建立 AuthenticationTicket 然後返回。
這段程式碼中有幾點值得關注:
- BasicAuthenticationSchemeOptions 本身只是一個繼承於 AuthenticationSchemeOptions 的 POCO 類。AuthenticationSchemeOptions 類通常是為了向 AuthenticationHandler 提供一些輸入引數。比如,在某個自定義的使用者認證邏輯中,可能需要通過環境變數讀入字串解密的金鑰資訊,此時就可以在這個自定義的 AuthenticationSchemeOptions 中增加一個 Passphrase 的屬性,然後在 Startup.cs 中,通過 service.AddScheme 呼叫將從環境變數中讀取的Passphrase 的值傳入。
- 除了將使用者名稱作為 Identity Claim 加入到 ClaimsPrincipal 中之外,我們還將使用者的角色(Role)用逗號串聯起來,作為 Role Claim 新增到ClaimsPrincipal 中,目前我們暫時不需要涉及角色相關的內容,但是先將這部分程式碼放在這裡以備後用。另外,我們將使用者的年齡(Age)放在 UserData claim中,在實際中應該是在使用者物件上有該使用者的出生日期,這樣比較合理,然後這個出生日期應該放在 DateOfBirth claim 中,這裡為了簡單起見,就先放在UserData 中了。
- ClaimsPrincipal 的建構函式中,可以指定哪個 Claim型別可被用作使用者名稱稱,而哪個 Claim 型別又可被用作使用者的角色。例如上面程式碼中,我們選擇NameIdentifier 型別作為使用者名稱,而 Role 型別作為使用者角色,於是,在接下來的 Controller 程式碼中,由 NameIdentifier 這種 Claim 所指向的字串值,就會被看成使用者名稱而被繫結到 Identity.Name 屬性上。
回過頭來看看 BasicAuthenticationSchemeOptions 類,它的實現非常簡單:
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebAPIAuthSample", Version = "v1" });
});
services.AddAuthentication("Basic")
.AddScheme<BasicAuthenticationSchemeOptions, BasicAuthenticationHandler>(
"Basic", options => { });
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "WebAPIAuthSample v1"));
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
}
現在,執行應用程式,在 WeatherForecastController 的 Get 方法上設定斷點,然後執行上面的 curl 命令,當斷點被命中時,觀察 this.User 物件可以發現,IsAuthenticated 屬性變為了 true,Name 屬性也被設定為使用者名稱:
大多數身份認證框架會提供一些輔助方法來幫助開發人員將 AuthenticationHandler 註冊到應用程式中,例如,基於 JWT 持有者身份認證的框架會提供一個 AddJwtBearer 的方法,將 JWT 身份認證機制加入到應用程式中,它本質上也是呼叫 AddScheme 方法來完成 AuthenticationHandler 的註冊。在這裡,我們也可以自定義一個 AddBasicAuthentication 的擴充套件方法:
public static class Extensions
{
public static AuthenticationBuilder AddBasicAuthentication(this AuthenticationBuilder builder)
=> builder.AddScheme<BasicAuthenticationSchemeOptions, BasicAuthenticationHandler>(
"Basic",
options => { });
}
然後修改 Starup.cs 檔案,將 ConfigureServices 方法改為下面這個樣子:
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebAPIAuthSample", Version = "v1" });
});
services.AddAuthentication("Basic").AddBasicAuthentication();
}
這樣做的好處是,你可以為開發人員提供更多比較有針對性的配置認證機制的程式設計介面,這對於一個認證模組/框架的開發是一個很好的設計。
在curl 命令中,如果我們沒有指定 Authorization Header,或者 Authorization Header 的 值不正確,那麼 WeatherForecast API 仍然可以被呼叫,只不過IsAuthenticated 屬性為 false,也無法從 this.User 物件得到使用者資訊。其實,阻止未認證使用者訪問 API 並不是認證的事情,API 被未認證(或者說未登入)使用者訪問也是合理的事情,因此,要實現對於未認證使用者的訪問限制,就需要進一步實現 ASP.NET Core Web API的另一個安全控制元件:授權。
授權
與認證相比,授權的邏輯會比較複雜:認證更多是技術層面的事情,而授權則更多地與業務相關。市面上常見的認證機制頂多也就是那麼幾種或者十幾種,而授權的方式則是多樣化的,因為不同 app 不同業務,對於 app 資源訪問的授權需求是不同的。最為常見的一種授權方式就是 RBAC(Role Based Access Control,基於角色的訪問控制),它定義了什麼樣的角色對於什麼資源具有怎樣的訪問許可權。在 RBAC 中,不同的使用者都被賦予了不同的角色,而為了管理方便,又為具有相同資源訪問許可權的使用者設計了使用者組,而將訪問控制設定在使用者組上,更進一步,組和組之間還可以有父子關係。
請注意上面的粗體字,每一個粗體標註的詞語都是授權相關的概念,在 ASP.NET Core 中,每一個授權需求(Authorization Requirement)對應一個實現IAuthorizationRequirement 的類,並由AuthorizationHandler 負責處理相應的授權邏輯。簡單地理解,授權需求表示什麼樣的使用者才能夠滿足被授權的要求,或者說什麼樣的使用者才能夠通過授權去訪問資源。一個授權需求往往僅定義並處理一種特定的授權邏輯,ASP.NET Core 允許將多個授權需求組合成授權策略(Authorization Policy)然後應用到被訪問的資源上,這樣的設計可以保證授權需求的設計與實現都是小粒度的,從而分離不同授權需求的關注點。在授權策略的層面,通過組合不同授權需求從而達到靈活實現授權業務的目的。
比如:假設 app 中有的 API 只允許管理員訪問,而有的 API 只允許滿18週歲的使用者訪問,而另外的一些 API 需要使用者既是超級管理員又滿18歲。那麼就可以定義兩種 Authorization Requirement:GreaterThan18Requirement 和SuperAdminRequirement,然後設計三種Policy:第一種只包含 GreaterThan18Requirement,第二種只包含 SuperAdminRequirement,第三種則同時包含這兩種 Requirement,最後將這些不同的 Policy 應用到不同的 API 上就可以了。
回到我們的案例程式碼,首先定義兩個 Requirement:SuperAdminRequirement 和 GreaterThan18Requirement:
public class SuperAdminRequirement : IAuthorizationRequirement
{
}
public class GreaterThan18Requirement : IAuthorizationRequirement
{
}
然後分別實現 SuperAdminAuthorizationHandle 和GreaterThan18AuthorizationHandler:
實現邏輯也非常清晰:在 GreaterThan18AuthorizationHandler 中,通過UserData claim 獲得年齡資訊,如果年齡大於18,則授權成功;在 SuperAdminAuthorizationHandler 中,通過 Role claim 獲得使用者所處的角色,如果角色中包含 super_admin,則授權成功。接下來就需要將這兩個 Requirement 加到所需的 Policy 中,然後註冊到應用程式裡:
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebAPIAuthSample", Version = "v1" });
});
services.AddAuthentication("Basic").AddBasicAuthentication();
services.AddAuthorization(options =>
{
options.AddPolicy("AgeMustBeGreaterThan18", builder =>
{
builder.Requirements.Add(new GreaterThan18Requirement());
});
options.AddPolicy("UserMustBeSuperAdmin", builder =>
{
builder.Requirements.Add(new SuperAdminRequirement());
});
});
services.AddSingleton<IAuthorizationHandler, GreaterThan18AuthorizationHandler>();
services.AddSingleton<IAuthorizationHandler, SuperAdminAuthorizationHandler>();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "WebAPIAuthSample v1"));
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
}
在 ConfigureServices 方法中,我們定義了兩種 Policy:AgeMustBeGreaterThan18 和 UserMustBeSuperAdmin,最後,在 API Controller 或者 Action 上,應用 AuthorizeAttribute,從而指定所需的 Policy 即可。比如,如果希望 WeatherForecase API 只有年齡大於18歲的使用者才能訪問,那麼就可以這樣做:
[HttpGet]
[Authorize(Policy = "AgeMustBeGreaterThan18")]
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();
}
執行程式,假設有三個使用者:daxnet、admin 和 foo,它們的 BASE64 認證資訊分別為:
- daxnet:ZGF4bmV0OnBhc3N3b3Jk
- admin:YWRtaW46YWRtaW4=
- foo:Zm9vOmJhcg==
那麼,相同的 curl 命令,指定不同的使用者認證資訊時,得到的結果是不一樣的:
daxnet 使用者年齡小於18歲,所以訪問 API 不成功,服務端返回403:
admin 使用者滿足年齡大於18歲的條件,所以可以成功訪問 API:
而 foo 使用者本身沒有在系統中註冊,所以服務端返回401,表示使用者沒有認證成功:
小結
本文簡要介紹了 ASP.NET Core 中使用者身份認證與授權的基本實現方法,幫助初學者或者需要使用這些功能的開發人員快速理解這部分內容。ASP.NET Core 的認證與授權體系非常靈活,能夠整合各種不同的認證機制與授權方式,文章也無法進行全面詳細的介紹。不過無論何種框架哪種實現,它的實現基礎也就是本文所介紹的這些內容,如果打算自己開發一套認證和授權的框架,也可以參考本文。