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參數即可~

后記

呼~

終於搞定了

沒想到這篇博客寫了這么長這么久

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

所以這篇作為基礎入門,接下來的博客會繼續深入這方面,沖!

參考資料


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM