1. 程式人生 > 其它 >Asp-Net-Core學習筆記:身份認證入門 _

Asp-Net-Core學習筆記:身份認證入門 _

前言

過年前我又來更新了~

我就說了最近不是在偷懶吧,其實這段時間還是有積累一些東西的,不過還沒去整理……

所以只能發以前沒寫完的一些筆記出來

就當做是溫習一下啦

PS:之前說的紅包封面我還沒搞,得抓緊時間了

最近在準備搞一個我之前做的開源專案程式碼合集來做一期分享

兩種常見的認證方式

先來看看兩種常見的認證方式:基於token的認證和傳統的session認證的區別。

session認證

我們知道,http協議本身是一種無狀態的協議,而這就意味著如果使用者向我們的應用提供了使用者名稱和密碼來進行使用者認證,那麼下一次請求時,使用者還要再一次進行使用者認證才行,因為根據http協議,我們並不能知道是哪個使用者發出的請求,所以為了讓我們的應用能識別是哪個使用者發出的請求,我們只能在伺服器儲存一份使用者登入的資訊,這份登入資訊會在響應時傳遞給瀏覽器,告訴其儲存為cookie,以便下次請求時傳送給我們的應用,這樣我們的應用就能識別請求來自哪個使用者了,這就是傳統的基於session認證。

但是這種基於session的認證使應用本身很難得到擴充套件,隨著不同客戶端使用者的增加,獨立的伺服器已無法承載更多的使用者,而這時候基於session認證應用的問題就會暴露出來。

弊端

Session: 每個使用者經過我們的應用認證之後,我們的應用都要在服務端做一次記錄,以方便使用者下次請求的鑑別,通常而言session都是儲存在記憶體中,而隨著認證使用者的增多,服務端的開銷會明顯增大。

擴充套件性: 使用者認證之後,服務端做認證記錄,如果認證的記錄被儲存在記憶體中的話,這意味著使用者下次請求還必須要請求在這臺伺服器上,這樣才能拿到授權的資源,這樣在分散式的應用上,相應的限制了負載均衡器的能力。這也意味著限制了應用的擴充套件能力。

CSRF: 因為是基於cookie來進行使用者識別的, cookie如果被截獲,使用者就會很容易受到跨站請求偽造的攻擊。

基於token的認證

基於token的鑑權機制類似於http協議也是無狀態的,它不需要在服務端去保留使用者的認證資訊或者會話資訊。這就意味著基於token認證機制的應用不需要去考慮使用者在哪一臺伺服器登入了,這就為應用的擴充套件提供了便利。

流程上是這樣的:

  • 使用者使用使用者名稱密碼來請求伺服器
  • 伺服器進行驗證使用者的資訊
  • 伺服器通過驗證傳送給使用者一個token
  • 客戶端儲存token,並在每次請求時附送上這個token值
  • 服務端驗證token值,並返回資料

這個token必須要在每次請求時傳遞給服務端,它應該儲存在請求頭裡, 另外,服務端要支援CORS(跨來源資源共享)

策略,一般我們在服務端這麼做就可以了Access-Control-Allow-Origin: *

OAuth2.0與OpenID

OAuth2.0OpenID Connect是標準驗證框架

OAuth(Open Authorization,即開放授權)是一個用於代理授權的標準協議。它允許應用程式在不提供使用者密碼的情況下訪問該使用者的資料。

OpenID Connect 是在 OAuth2.0 協議之上的標識層。它拓展了 OAuth2.0,使得認證方式標準化。

OAuth 不會立即提供使用者身份,而是會提供用於授權的訪問令牌。OpenID Connect 使客戶端能夠通過認證來識別使用者,其中,認證在授權服務端執行。它是這樣實現的:在向授權服務端發起使用者登入和授權告知的請求時,定義一個名叫openid的授權範圍。在告知授權伺服器需要使用 OpenID Connect 時,openid是必須存在的範圍。

來看一看OpenID Connect的架構圖,可以看到,JWT是作為它的底成實現支援。所以,對於瞭解JWT來說是必要的。

那麼我們繼續瞭解接下來的JWT。

JWT

Json web token (JWT), 是為了在網路應用環境間傳遞宣告而執行的一種基於JSON的開放標準((RFC 7519)。該token被設計為緊湊且安全的,特別適用於分散式站點的單點登入(SSO)場景。JWT的宣告一般被用來在身份提供者和服務提供者間傳遞被認證的使用者身份資訊,以便於從資源伺服器獲取資源,也可以增加一些額外的其它業務邏輯所必須的宣告資訊,該token也可直接被用於認證,也可被加密。

對於我們常用的JWT,是採用了JWS的簽名式加密方案。所以結構就是 "A.B.C"的樣子,用Header來描述了簽名加密所用的演算法,該描述遵循了JWA,而使用Payload來包含咱們所需要的東西,在JWT裡面,它們叫做JWT Claims Set,而JWT提出了很多內建的Claim規範,下面我們會看到。最後是Signature,這就是基於JWS所得到的內容。

JWT規範定義了七個可選的、已註冊的宣告(Claim),並允許將公共和私人宣告包括在令牌中,這七個已登記的宣告是:

Claim 描述
iss (Issuer) 確定了簽發JWT的主體(發行者)。一般是STRING或者URI,比如"http://my.identityServer.com/5000"
sub (Subject) JWT所代表的主題。主題值必須限定為在發行者的上下文中是本地唯一的,或者是全域性唯一的。所以你會在某些例子中看到它儲存了使用者的ID等。一般是STRING或者URI
aud (Audience) JWT的受眾(該單詞我也不知道該如何翻譯比較合適)。一般是STRING或者URI,比如"http://my.clientiIp.com/5000"
exp (expire) JWT的過期時間
nbf (not-before) JWT的生效時間
iat ((issued-at) JWT的頒發時間
jti (expire) JWT的唯一識別符號(JWT ID)

當然,僅僅靠這些值我們一般是無法處理完整業務邏輯的,比如我們往往需要將使用者郵箱等資訊放入Token中,所以我們可以在荷載中放入我們自定義的一些項,只要保證不要和內建的命名衝突就行啦。

具體要怎麼寫,下面有程式碼例子~

Bearer Token

BearerHTTP Authorization的型別規範,而JWT是一個資料結構的規範

HTTP 1.0中提出了Authorization: <type> <credentials>這樣的格式。 如果Basic型別的驗證是Authorization : Basic,那Bearer型別就是 Authorization : Bearer <token>

關於Bearer,它是伴隨OAuth2.0所提出,該規範僅僅定義了Bearer Token的格式(也就是需要帶上Bearer關鍵字在header中),並沒有說過Token一定要使用JWT格式。

再捋一遍

前面介紹了這麼多的概念之後,可能同學們已經有點暈暈的了,沒事,接下來重新捋一遍

使用者登入,首先要在客戶端請求服務端的登入介面,把使用者名稱和密碼發給伺服器;

然後伺服器把使用者名稱和密碼拿去資料庫裡比對,如果正確的話,那就根據JWT標準生成一個JWT token返回給客戶端;

客戶端拿到了token,就能以Bearer token的形式將token放在HTTP請求頭中,去請求那些需要登入才能訪問的介面~

就是這麼簡單~

AspNetCore中的認證授權

在開始寫程式碼之前,必須要了解一下AspNetCore中關於認證與授權的基礎概念~

認證

身份認證處理程式是實現身份認證操作的核心類,身份認證處理程式派生自介面IAuthenticationHandler。該介面定義了以下三種操作:身份認證(AuthenticateAsync)、挑戰(ChallengeAsync)和禁止(ForbidAsync)。其中,身份認證是主要的操作。

身份認證返回AuthenticateResult來表明該請求的身份認證是否成功,AuthenticateResult可以返回三種類型的結果:失敗(Fail)、無結果(NoResult)和成功(Success)。如果驗證成功,將會通過AuthenticateResult返回AuthenticationTicketAuthenticationTicket將會封裝使用者資訊,以便於在後續的授權中使用。

挑戰是指當前請求訪問的資源要求身份認證,但是當前請求未通過身份認證,那麼後續的授權階段就需要通過指定的身份認證方案中的身份認證處理程式來提供挑戰方法,以便發起挑戰。如果沒有指定身份認證方案,就會使用預設身份認證方案。舉個例子來說,如果我們因為長時間沒有操作而導致系統登入會話超時失效,那麼再次對系統進行操作時,系統一般會將頁面重定向到登入頁面,這個重定向的操作就是一種挑戰。

禁止是指已經通過身份認證的使用者嘗試訪問其無權訪問的資源時而進入授權階段所要執行的操作。比如某站點的普通使用者想要使用VIP使用者的功能,如果該使用者沒有登入,那麼本次請求就是匿名訪問,這時授權階段需要發起挑戰操作;如果該使用者已經登入,但是授權階段發現該使用者沒有許可權訪問該資源,那麼系統可能會返回HTTP 403狀態碼,這種返回HTTP 403狀態碼的操作就是一種禁止。

使用者資訊模型

身份認證通過後,身份認證處理程式會返回身份認證票根,即AuthenticationTicket

AuthenticationTicket是ASP.NET Core封裝認證資訊的類。

AuthenticationTicket又包含了ClaimsPrincipalClaimsPrincipal可以理解為使用者主體,由一組ClaimIdentity組成。

ClaimsIdentity可以理解為身份證明,一個使用者主體可以有多個身份證明,就好比身份證、駕駛證都可以代表唯一具體的人一樣。

ClaimsIdentity包含了一組ClaimClaim就是好比身份證上的姓名、性別、籍貫等資訊。一個使用者通過身份認證後,就會用以上類來組織使用者資訊。後續的授權等其他中介軟體就可以使用這些資訊來進行功能設計。

結構示例如下:

  • AuthenticationTicket (身份認證票根,其中封裝了認證資訊)
    • ClaimsPrincipal (使用者主體)
      • ClaimIdentity (身份證明)
        • Claim
        • Claim
        • Claim
      • ClaimIdentity
      • ClaimIdentity

授權

授權(Authorization)決定了一個使用者在系統裡能幹什麼。對於ASP.NET Core應用來說,授權決定了一個使用者能夠訪問哪些資源路徑。授權與7.1節講的身份認證是依賴和被依賴的關係,ASP.NETCore將身份認證與授權設計成了相對獨立的兩個功能模組,兩個模組的職責分工非常明確,前者解決使用者是誰的問題,後者解決使用者能幹什麼的問題。從功能上來看,授權是基於身份認證的結果而做出的,從邏輯上來說,只有知道使用者是誰才能確定使用者能幹什麼。

ASP.NET Core提供了簡單授權、基於角色的授權、基於策略的授權,多樣的授權方式在通過簡單的Attribute修飾就能滿足大部分應用場景。授權中重要的兩個Attribute就是AuthorizeAttributeAllowAnonymousAttribute,所有的授權配置都離不開這兩個Attribute。同時,ASP.NET Core對授權方案的擴充套件也非常方便,在本節的最後會介紹如何自定義授權處理程式來實現自定義授權邏輯。

授權有這三種類型

  • 簡單授權:只要登入就能訪問,在Controller或者Action上加個[Authorize]就行
  • 基於角色的授權:特定角色能訪問
  • 基於策略的授權:顧名思義

基於角色的授權

基於角色的授權簡單來說就是一個資源必須要指定角色的使用者才能夠訪問。基於角色的授權必須在Controller或Action上指定哪些角色可以訪問該資源。

AuthorizeAttribute有一個公開的string型別的屬性Roles。通過這個屬性可以指定哪些角色可以訪問特定資源。認證使用者是否屬於某個角色可以通過ClaimsPrincipal類的IsInRole方法進行驗證,ASP.NET Core基於角色的授權就是通過這個方法來確定當前使用者是否屬於某個角色使用者的。當前使用者屬於角色屬性如何設定呢?很簡單,ClaimsIdentity的屬性RoleClaimType會告訴ASP.NET Core哪個Claim儲存了使用者的角色資訊。

比如某個Controller需要管理員角色才能訪問:

 
[Authorize(Roles="管理員")]

可以指定多個角色都可以訪問,多個角色間用逗號分隔:

 
[Authorize(Roles="人力經理,財務")]

如果用多個[Authorize(Roles='SomeRole')]修飾ControllerAction,那麼訪問的使用者必須是所有指定角色的成員,下面的例子必須同時是“銷售”和“經理”才能訪問

 
[Authorize(Roles="銷售")]
[Authorize(Roles="經理")]

基於策略的授權

基於策略的授權是更靈活的授權方式,我們先來了解ASP.NET Core的授權模型。與身份認證相似,ASP.NET Core由授權處理程式、授權需求、授權方案、授權服務構成。其中,授權服務同身份認證服務一樣,作為授權服務介面對外提供授權能力。授權方案是組織授權機制的概念,一個授權方案可以包含多個授權需求,只有滿足了所有授權需求才算通過了授權方案,而授權需求可以關聯多個授權處理程式,任意一個授權處理程式返回授權成功,則表示該授權方案下的授權需求被滿足了。

ASP.NET Core提供了一個授權策略,實現了建造者模式,通過AuthorizationPolicyBuilder可以方便地構建AuthorizationPolicy。基於AuthorizationPolicyBuilder,可以方便地設定授權策略的授權需求。

 
services.AddAuthorization(config => {
    config.AddPolicy("RequireAdmin", builder => builder.RequireRole("管理員"));
});

除了AuthorizeAttribute上可以設定的角色外,還可以設定Claim需求。

該授權策略需要當前認證使用者姓"趙":

 
services.AddAuthorization(config => {
    config.AddPolicy("RequireZhao", builder => builder.RequireClaim("姓", "趙"));
});

如果被授權的姓氏規則比較複雜,不利於枚舉出來,那麼推薦使用RequireAssertion來實現。比如上面的功能還可以用如下方式來實現:

 
services.AddAuthorization(config => {
    config.AddPolicy("RequireZhao", builder => builder.RequireAssertion(
        context => context.User.FindFirst("姓").Value=="趙"));
});

除此之外,還可以通過實現了IAuthorizationRequirement的授權需求來關聯自定義的授權處理程式來實現更靈活的授權規則設計。

IdentityServer4

IdentityServer4是ASP.NET Core平臺下的一個OAuth 2.0以及OpenID Connect的實現。它非常方便地提供了身份認證、授權以及第三方認證服務對接,並且支援自定義方式來滿足開發者不同場景下各式各樣的需求。IdentityServer4作為一個成熟的認證授權框架,是受到OpenID Connect官方認證的服務端實現。IdentityServer開源且免費,在重視智慧財產權的今天,我們可以放心地基於IdentityServer4搭建認證平臺開發商業應用。

IdentityServer通過IdentityResource、ApiScope、ApiResource、Client這些概念來實現身份的認證和資源的許可權控制。

IdentityResource是指使用者ID、姓名、手機號等使用者資訊,比如OpenID Connect規範就定義了一組標準的IdentityResource。除此之外,我們也可以自定義IdentityResource,這些概念很像ASP.NETCore中身份認證的Claim,定義了程式能訪問到的使用者資訊。

ApiScope可以認為是API的一種標籤,而ApiResource就是對API在授權場景下的抽象。如果需要對客戶端能否訪問某個API進行控制,就要定義ApiScope和ApiResource。

Client通過Request Token限制了哪些應用可以訪問對應的API資源。每個Client都會有一個唯一的Client ID,通過設定一個祕鑰可以加強使用者資訊保安性,關鍵的是通過設定AllowedApiScopes,框架就可以控制這個Client可以訪問哪些ApiResource(Resource是和Scope相關聯的)。

開始編碼!

OK,終於到了激動人心的寫程式碼環節,書讀百遍不如實踐一次,開始吧!

首先根據JWT標準,我們需要先定義這幾個資訊:

  • Issuer:簽發JWT的主體
  • Audience:JWT的受眾
  • Key:用來加密的祕鑰

本例子中我們寫一個最簡單的單站點登入認證,所以Audience可以寫死在配置檔案裡。

定義配置類

為了方便的對映appsettings.json配置檔案,我們定義一個類(誤,是兩個)

 
public class SecuritySettings {
    public Token Token { get; set; }
}
public class Token {
    public string Issuer { get; set; }
    public string Audience { get; set; }
    public string Key { get; set; }
}

然後註冊服務

 
services.Configure<SecuritySettings>(configuration.GetSection(nameof(SecuritySettings)));

新增認證服務和中介軟體

新增認證服務

 
services.AddAuthentication(options => {
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
    .AddJwtBearer(options => {
        // 這裡用到我們之前定義好的配置類
        var secSettings = configuration.GetSection(nameof(SecuritySettings)).Get<SecuritySettings>();
        // 設定jwt token的各種資訊用於驗證
        options.TokenValidationParameters = new TokenValidationParameters {
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuer = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = secSettings.Token.Issuer,
            ValidAudience = secSettings.Token.Audience,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secSettings.Token.Key)),
            ClockSkew = TimeSpan.Zero
        };
    });

新增中介軟體

app.UseEndpoints前面新增這三行程式碼

 
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

使用者實體類

很簡單,不多說了

 
public class LoginUser {
    public string Username { get; set; }
    public string Password { get; set; }
}

登入介面

在Controller裡寫一個使用者登入介面

 
[HttpPost]
public ActionResult<LoginToken> Login(LoginUser loginUser) {
    var user = _authService.GetUser(loginUser.Username);
    if (user == null) return NotFound();

    var md5Pwd = loginUser.Password.MDString();
    if (md5Pwd != user.Password) return Unauthorized();

    return _authService.GenerateLoginToken(user);
}

這裡面我封裝了一個AuthService服務,專門用於處理跟使用者認證有關的操作

其中的GetUser方法不用多介紹了,就是資料庫讀取操作而已。

我們主要看GenerateLoginToken這個方法。

生成token的關鍵程式碼

GenerateLoginToken方法的程式碼如下

 
public LoginToken GenerateLoginToken(User user) {
    // 構造JWT中的claims資訊
    var claims = new List<Claim> {
        new("username", user.Name),
        new(JwtRegisteredClaimNames.Name, user.Id), // User.Identity.Name
        new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // JWT ID
    };
    
    // 從配置檔案裡讀取資訊
    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secSettings.Token.Key));
    var signCredential = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
    var jwtToken = new JwtSecurityToken(
        issuer: _secSettings.Token.Issuer,		// 頒發者資訊
        audience: _secSettings.Token.Audience,	// 接受者資訊
        claims: claims,							// 要放進JWT中的claims資訊
        expires: DateTime.Now.AddDays(7),		// 過期時間
        signingCredentials: signCredential);	// 簽名

    // 最後返回一個 LoginToken 物件,其中包含JWT token和過期時間兩個欄位
    return new LoginToken {
        Token = new JwtSecurityTokenHandler().WriteToken(jwtToken),
        Expiration = TimeZoneInfo.ConvertTimeFromUtc(jwtToken.ValidTo, TimeZoneInfo.Local)
    };
}

這個程式碼的意義我都寫在註釋裡面了,最後的LoginToken是我定義的一個類,程式碼很簡單:

 
public class LoginToken {
    public string? Token { get; set; }
    public DateTime Expiration { get; set; }
}

效果

完成之後,訪問登入介面,提交正確的使用者名稱密碼,就可以得到客戶端要的JWT token,大概是下面這樣的形式

 
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ5ZXpzIiwibmFtZSI6InllenMiLCJwaG9uZV9udW1iZXIiOiIxNTYwMjc3NzMwMCIsImV4cCI6MTY0MzMxMzc3OSwiaXNzIjoiZGVtb19pc3N1ZXIiLCJhdWQiOiJkZW1vX2F1ZGllbmNlIn0.7x8zfpcWWbCH6SwXOUnQKCfXRWsyUiWoB5jSxYSIq-Q",
  "expiration": "2022-01-28T04:02:59+08:00"
}

在需要登入的介面方法或者Controller類上加一個[Authorize]特性,就OK了

訪問的時候如果不帶上HTTP頭Authorization : Bearer <token>,就會報401 Unauthorized錯誤。

大功告成!

SignalR中如何使用JWT Token?

接下來是一點擴充套件的東西

AspNetCore除了可以做WebApi這種基於HTTP的介面,還可以實現像websocket這樣的實時通訊,比如SignalR技術

那通過SignalR的請求能不能也加上身份驗證呢?答案是肯定的

和controller一樣,只需要在Hub類或者Hub類裡面的方法加上[Authorize]特性,即可實現身份驗證。

但是客戶端訪問的時候要怎麼提交token呢?這可不是HTTP,沒有header的

別急,來看看以下兩種方法,都是要在前面新增服務那裡配置。

首先確定要新增配置的地方:

 
services.AddAuthentication(...)
    .AddJwtBearer(options => {
        options.TokenValidationParameters = new TokenValidationParameters {...};
        options.Events = new JwtBearerEvents {
            // 等會要新增的配置程式碼放在這裡...
        };
    });

官方文件的方法

 
OnMessageReceived = context => {
    var accessToken = context.Request.Query["access_token"];
    var path = context.HttpContext.Request.Path;
    // If the request is for our hub
    if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub")) {
        // Read the token out of the query string
        context.Token = accessToken;
    }
    return Task.CompletedTask;
}

簡書網友的方法

 
OnMessageReceived = context => {
    var accessToken = context.Request.Query["access_token"];
    if (!string.IsNullOrEmpty(accessToken) &&
        (context.HttpContext.WebSockets.IsWebSocketRequest || context.Request.Headers["Accept"] == "text/event-stream")){
        context.Token = context.Request.Query["access_token"];
    }
    return Task.CompletedTask;
}

點評一下,官方文件的方法有點硬編碼,是根據請求的路徑判斷的,但如果我們的專案裡不止一個hub,那就麻煩了,要多寫點程式碼;

簡書網友的方法是根據請求的方式來判斷,我們知道SignalR和普通的HTTP請求是不一樣的,所以感覺簡書網友的這個方法更優雅一點~

客戶端使用

差點把這個忘了

放上JavaScript程式碼~

 
let loginToken = "xxx"
let connection = new signalR.HubConnectionBuilder()
    .withUrl("/hub/hub_name", {accessTokenFactory: () => loginToken})
    .build()

在建立連線的時候,帶上accessTokenFactory引數即可~

後記

呼~

終於搞定了

沒想到這篇部落格寫了這麼長這麼久

授權與認證包括好多要學的東西,我目前也只是做了最基礎的登入驗證,還沒有搞身份那些

所以這篇作為基礎入門,接下來的部落格會繼續深入這方面,衝!

參考資料