理解ASP.NET Core - 基於Cookie的身份認證(Authentication)

xiaoxiaotank發表於2022-01-17

注:本文隸屬於《理解ASP.NET Core》系列文章,請檢視置頂部落格或點選此處檢視全文目錄

概述

通常,身份認證(Authentication)和授權(Authorization)都會放在一起來講。但是,由於這倆英文相似,且“認證授權”四個字經常連著用,導致一些剛接觸這塊知識的讀者產生混淆,分不清認證和授權的區別,甚至認為這倆是同一個。所以,我想先給大家簡單區分一下身份認證和授權。

身份認證

確認執行操作的人是誰。

當使用者請求後臺服務時,系統首先需要知道使用者是誰,是張三、李四還是匿名?確認身份的這個過程就是“身份認證”。在我們的實際生活中,通過出示自己的身份證,別人就可以快速地確認你的身份。

授權

確認操作人是否有執行該項操作的許可權。

確認身份後,已經獲悉了使用者資訊,隨後來到授權階段。在本階段,要做的是確認使用者有沒有執行該項操作的許可權,如確認張三有沒有商品檢視許可權、有沒有編輯許可權等。

Cookie對於許多人來說,是一個再熟悉不過的東西,熟悉到現在的Web應用,基本離不開它,如果你對Cookie還不太瞭解,也別慌,我在文末給大家整理了一些高質量的文章,推薦對Cookie有一個整體的瞭解之後,再來繼續閱讀下方的內容!

基於Cookie進行身份認證,通常的方案是使用者成功登入後,服務端將使用者的必要資訊記錄在Cookie中,併傳送給瀏覽器,後續當使用者傳送請求時,瀏覽器將Cookie傳回服務端,服務端就可以通過Cookie中的資訊確認使用者資訊了。

在開始之前,為了方便大家理解並能夠實際操作,我已經準備好了一個示例程式,請訪問XXTk.Auth.Samples.Cookies.Web獲取原始碼。文章中的程式碼,基本上在示例程式中均有實現,強烈建議組合食用!

身份認證(Authentication)

新增身份認證中介軟體

在 ASP.NET Core 中,為了進行身份認證,需要在HTTP請求管道中通過UseAuthentication新增身份認證中介軟體——AuthenticationMiddleware

public class Startup
{
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Home/Error");
        }
        app.UseStaticFiles();
    
        app.UseRouting();
    
        // 身份認證中介軟體
        app.UseAuthentication();
    
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=Index}/{id?}");
        });
    }
}

UseAuthentication一定要放在UseEndpoints之前,否則Controller中無法通過HttpContext獲取身份資訊。

AuthenticationMiddleware做的事情很簡單,就是確認使用者身份,在程式碼層面上就是給HttpContext.User賦值,請參考下方程式碼:

public class AuthenticationMiddleware
{
    private readonly RequestDelegate _next;

    public AuthenticationMiddleware(RequestDelegate next, IAuthenticationSchemeProvider schemes)
    {
        _next = next;
        Schemes = schemes;
    }

    public IAuthenticationSchemeProvider Schemes { get; set; }

    public async Task Invoke(HttpContext context)
    {
        // 記錄原始路徑和原始基路徑
        context.Features.Set<IAuthenticationFeature>(new AuthenticationFeature
        {
            OriginalPath = context.Request.Path,
            OriginalPathBase = context.Request.PathBase
        });

        // 如果有顯式指定的身份認證方案,優先處理(這裡不用看,直接看下面)
        var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
        foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
        {
            var handler = await handlers.GetHandlerAsync(context, scheme.Name) as IAuthenticationRequestHandler;
            if (handler != null && await handler.HandleRequestAsync())
            {
                return;
            }
        }

        // 使用預設的身份認證方案進行認證,並賦值 HttpContext.User
        var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
        if (defaultAuthenticate != null)
        {
            var result = await context.AuthenticateAsync(defaultAuthenticate.Name);
            if (result?.Principal != null)
            {
                context.User = result.Principal;
            }
        }

        await _next(context);
    }
}

配置Cookie認證方案

現在,認證中介軟體已經加好了,現在需要在ConfigureServices方法中新增身份認證所需要用到的服務並進行認證方案配置。

我們可以通過AddAuthentication擴充套件方法來新增身份認證所需要的服務,並可選的指定預設認證方案的名稱,以下方為例:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme);
    }
}

我們新增了身份認證所依賴的服務,並指定了一個名為CookieAuthenticationDefaults.AuthenticationScheme的預設認證方案,即Cookies。很明顯,它是一個基於Cookie的身份認證方案。

CookieAuthenticationDefaults是一個靜態類,定義了一些常用的預設值:

public static class CookieAuthenticationDefaults
{
    // 認證方案名
    public const string AuthenticationScheme = "Cookies";

    // Cookie名字的字首
    public static readonly string CookiePrefix = ".AspNetCore.";
    
    // 登入路徑
    public static readonly PathString LoginPath = new PathString("/Account/Login");

    // 登出路徑
    public static readonly PathString LogoutPath = new PathString("/Account/Logout");

    // 訪問拒絕路徑
    public static readonly PathString AccessDeniedPath = new PathString("/Account/AccessDenied");

    // return url 的引數名
    public static readonly string ReturnUrlParameter = "ReturnUrl";
}

現在,我們已經指定了預設認證方案,接下來就是來配置這個方案的細節,通過後跟AddCookie來實現:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme);
            .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
            {
                // 在這裡對該方案進行詳細配置
            });
    }
}

很明顯,AddCookie的第一個引數就是指定該認證方案的名稱,第二個引數是詳細配置。

通過options,可以針對登入、登出、Cookie等方面進行詳細配置。它的型別為CookieAuthenticationOptions,繼承自AuthenticationSchemeOptions。 屬性實在比較多,我就選擇一些比較常用的來講解一下。

另外,由於在針對選項進行配置時,需要依賴DI容器中的服務,所以不得不將選項的配置從AddCookie擴充套件方法中提出來。

請檢視以下程式碼:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
            .Configure<IDataProtectionProvider>((options, dp) =>
            {
                options.LoginPath = new PathString("/Account/Login");
                options.LogoutPath = new PathString("/Account/Logout");
                options.AccessDeniedPath = new PathString("/Account/AccessDenied");
                options.ReturnUrlParameter = "returnUrl";

                options.ExpireTimeSpan = TimeSpan.FromDays(14);
                //options.Cookie.Expiration = TimeSpan.FromMinutes(30);
                //options.Cookie.MaxAge = TimeSpan.FromDays(14);
                options.SlidingExpiration = true;
                
                options.Cookie.Name = "auth";
                //options.Cookie.Domain = ".xxx.cn";
                options.Cookie.Path = "/";
                options.Cookie.SameSite = SameSiteMode.Lax;
                options.Cookie.HttpOnly = true;
                options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
                options.Cookie.IsEssential = true;
                options.CookieManager = new ChunkingCookieManager();
                
                options.DataProtectionProvider ??= dp;
                var dataProtector = options.DataProtectionProvider.CreateProtector("Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationMiddleware", CookieAuthenticationDefaults.AuthenticationScheme, "v2");
                options.TicketDataFormat = new TicketDataFormat(dataProtector);

                options.Events.OnSigningIn = context =>
                {
                    Console.WriteLine($"{context.Principal.Identity.Name} 正在登入...");
                    return Task.CompletedTask;
                };

                options.Events.OnSignedIn = context =>
                {
                    Console.WriteLine($"{context.Principal.Identity.Name} 已登入");
                    return Task.CompletedTask;
                };
                
                options.Events.OnSigningOut = context =>
                {
                    Console.WriteLine($"{context.HttpContext.User.Identity.Name} 登出");
                    return Task.CompletedTask;
                };

                options.Events.OnValidatePrincipal += context =>
                {
                    Console.WriteLine($"{context.Principal.Identity.Name} 驗證 Principal");
                    return Task.CompletedTask;
                };
            });
        services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
            .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
    }
}

以上配置,大多使用了程式的預設值,接下來一一進行詳細講解:

  • LoginPath:登入頁路徑,指向一個Action
    • 預設/Account/Login
    • 當服務端不允許匿名訪問而需要確認使用者資訊時,跳轉到該頁面進行登入。
    • 另外,登入方法通常會有一個引數,叫作return url,用來當使用者登入成功時,自動跳轉回之前訪問的頁面。這個引數也會自動傳遞給該Action,下方會詳細說明。
  • LogoutPath:登出路徑,指向一個Action。預設/Account/Logout
  • AccessDeniedPath:訪問拒絕頁路徑,指向一個Action。預設/Account/AccessDenied。當出現Http狀態碼 403 時,會跳轉到該頁面。
  • ReturnUrlParameter:上面提到的return url的引數名,引數值會通過 query 的方式傳遞到該引數中。預設ReturnUrl
  • ExpireTimeSpan:認證票據(authentication ticket)的有效期。
    • 預設 14 天
    • 認證票據在程式碼中表現為型別為AuthenticationTicket的物件,它就好像一個手提包,裡面放滿了可以證明你身份的物品,如身份證、駕駛證等。
    • 認證票據儲存在Cookie中,它的有效期與所在Cookie的有效期是獨立的,如果Cookie沒有過期,但是認證票據過期了,也無法通過認證。在下方講解登入部分時,有針對認證票據有效期的詳細說明。
  • Cookie.Expiration:Cookie的過期時間,即在瀏覽器中的儲存時間,用於持久化Cookie。
    • 對應Cookie中的Expires屬性,是一個明確地時間點。
    • 目前已被禁用,我們無法給它賦值。
  • Cookie.MaxAge:Cookie的過期時間,即在瀏覽器中的儲存時間,用於持久化Cookie。
    • 對應Cookie中的Max-Age屬性,是一個時間範圍。
    • 如果Cookie的Max-AgeExpires同時設定,則以Max-Age為準
    • 如果沒有設定Cookie的Expires,同時Cookie.MaxAge的值保持為null,那麼該Cookie的有效期就是當前會話(Session),當瀏覽器關閉後,Cookie便會被清除(實際上,現在的部分瀏覽器有會話恢復功能,瀏覽器關閉後重新開啟,Cookie也會跟著恢復,彷彿瀏覽器從未關閉一樣)。
  • SlidingExpiration:指示Cookie的過期方式是否為滑動過期。預設true。若為滑動過期,服務端收到請求後,如果發現Cookie的生存期已經超過了一半,那麼服務端會重新頒發一個全新的Cookie,Cookie的過期時間和認證票據的過期時間都會被重置。
  • Cookie.Name:該Cookie的名字,預設是.AspNetCore.Cookies
  • Cookie.Domain:該Cookie所屬的域,對應Cookie的Domain屬性。一般以“.”開頭,允許subdomain都可以訪問。預設為請求Url的域。
  • Cookie.Path:該Cookie所屬的路徑,對應Cookie的Path屬性。預設/
  • Cookie.SameSite:設定通過瀏覽器跨站傳送請求時決定是否攜帶Cookie的模式,共有三種,分別是NoneLaxStrict
    public enum SameSiteMode
    {
        Unspecified = -1,
        None,
        Lax,
        Strict
    }
    
    • SameSiteMode.Unspecified:使用瀏覽器的預設模式。
    • SameSiteMode.None:不作限制,通過瀏覽器傳送同站或跨站請求時,都會攜帶Cookie。這是非常不建議的模式,容易受到CSRF攻擊
    • SameSiteMode.Lax:預設值。通過瀏覽器傳送同站請求或跨站的部分GET請求時,可以攜帶Cookie。
    • SameSiteMode.Strict:只有通過瀏覽器傳送同站請求時,才會攜帶Cookie。
    • 更具體的內容,參考最下方的好文推薦
  • Cookie.HttpOnly:指示該Cookie能否被客戶端指令碼(如js)訪問。預設為true,即禁止客戶端指令碼訪問,這可以有效防止XSS攻擊
  • Cookie.SecurePolicy:設定Cookie的安全策略,對應於Cookie的Secure屬性。
    public enum CookieSecurePolicy
    {
        SameAsRequest,
        Always,
        None
    }
    
    • CookieSecurePolicy.Always:設定Secure=true,當傳送登入請求和後續請求均為Https時,瀏覽器才將Cookie傳送給服務端。
    • CookieSecurePolicy.None:不設定Secure,即傳送Http請求和Https請求時,瀏覽器都會將Cookie傳送給服務端。
    • CookieSecurePolicy.SameAsRequest:預設值。視情況而定,如果登入介面是Https請求,則設定Secure=true,否則,不設定。
  • Cookie.IsEssential:指示該Cookie對於應用的正常執行是必要的,不需要經過使用者同意使用
  • CookieManager:Cookie管理器,用於新增響應Cookie、查詢請求Cookie或刪除Cookie。預設是ChunkingCookieManager
  • DataProtectionProvider:認證票據加密解密提供器,可以按需提供相應的加密解密工具。預設是KeyRingBasedDataProtector。有關資料保護相關的知識,請參考官方文件-ASP.NET Core資料保護
  • TicketDataFormat:認證票據的資料格式,內部通過DataProtectionProvider提供的加密解密工具進行認證票據的加密和解密。預設是TicketDataFormat

以下是部分事件回撥:

  • Events.OnSigningIn:登入前回撥
  • Events.OnSignedIn:登入後回撥
  • Events.OnSigningOut:登出時回撥
  • Events.OnValidatePrincipal:驗證 Principal 時回撥

如果你覺得這樣註冊回撥不優雅,那你可以繼承自CookieAuthenticationEvents來實現自己的類,內部重寫對應的方法即可,如:

public class MyCookieAuthenticationEvents : CookieAuthenticationEvents {}

最後,在options處進行替換即可:options.EventsType = typeof(MyCookieAuthenticationEvents);

  • 跨域(Cross Origin):請求的Url與當前頁面的Url進行對比,協議、域名、埠號中任意一個不同,則視為跨域。
  • 跨站(Cross Site):跨站相對於跨域來說,規則寬鬆一些,請求的Url與當前頁面的Url進行對比,eTLD + 1不同,則視為跨站。

具體請參考Understanding "same-site" and "same-origin"

使用者登入和登出

使用者登入

現在,終於到了使用者登入和登出了。還記得嗎,方案中配置的登入、登出、禁止訪問路徑要和介面對應起來。

ASP.NET Core針對登入,提供了HttpContext的擴充套件方法SignInAsync,我們可以使用它進行登入。以下僅貼出Controller的程式碼,前端程式碼請參考github的原始碼。

public class AccountController : Controller
{
    [HttpGet]
    public IActionResult Login([FromQuery] string returnUrl = null)
    {
        ViewBag.ReturnUrl = returnUrl;

        return View();
    }

    [HttpPost]
    public async Task<IActionResult> Login([FromForm] LoginViewModel input)
    {
        ViewBag.ReturnUrl = input.ReturnUrl;

        // 使用者名稱密碼相同視為登入成功
        if (input.UserName != input.Password)
        {
            ModelState.AddModelError("UserNameOrPasswordError", "無效的使用者名稱或密碼");
        }

        if (!ModelState.IsValid)
        {
            return View();
        }

        var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
        identity.AddClaims(new[]
        {
            new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString("N")),
            new Claim(ClaimTypes.Name, input.UserName)
        });

        var principal = new ClaimsPrincipal(identity);

        // 登入
        var properties = new AuthenticationProperties
        {
            IsPersistent = input.RememberMe,
            ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(60),
            AllowRefresh = true
        };
        await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, properties);

        if (Url.IsLocalUrl(input.ReturnUrl))
        {
            return Redirect(input.ReturnUrl);
        }

        return Redirect("/");
    }
}

首先說一下ClaimIdentityPrincipal

  • Claim:表示一條資訊的宣告。以我們的身份證為例,裡面包含姓名、性別等資訊,如“姓名:張三”、“性別:男”,這些都是Claim。
  • Identity:表示一個身份。對於一個ClaimsIdentity來說,它是由一個或多個Claim組成的。我們的身份證就是一個Identity。
  • Principal:表示使用者本人。對於一個ClaimsPrincipal來說,它是由一個或多個ClaimsIdentity組成的。想一下,我們每個人的身份不僅僅只有一種,除了身份證外,還有駕駛證、會員卡等。

回到Login方法,首先宣告瞭一個ClaimsIdentity例項,並將CookieAuthenticationDefaults.AuthenticationScheme作為認證型別來傳入。需要注意的是,這個認證型別一定不要是null或空字串,否則,預設配置下,你會得到如下錯誤:

InvalidOperationException: SignInAsync when principal.Identity.IsAuthenticated is false is not allowed when AuthenticationOptions.RequireAuthenticatedSignIn is true.

隨後,我們將使用者的一些非敏感資訊作為Claim存入到了ClaimsIdentity中,並最終將其放入ClaimsPrincipal例項。

SignInAsync擴充套件方法中,我們可以針對認證進行一些配置,通過AuthenticationProperties

  • IsPersistent:票據是否持久化,即票據所在的Cookie是否持久化。如果持久化,則會將下方ExpiresUtc的值設定為Cookie的Expires屬性。預設為false
  • ExpiresUtc:票據的過期時間,預設為null,如果為null,則CookieAuthenticationHandler會在HandleSignInAsync方法中將Cookie認證方案配置中的CookieAuthenticationOptions.ExpireTimeSpan + AuthenticationProperties.IssuedUtc的結果賦值給該屬性。
  • AllowRefresh:上面提到過,在Cookie的認證方案配置中,可以將過期方式配置為滑動過期,滿足條件時,會重新頒發Cookie。實際上,要實現這個效果,還要將AllowRefresh設定為null或者true才可以。預設為null
  • IssuedUtc:票據頒發時間,預設為null。一般無需手動賦值,為null時,CookieAuthenticationHandler會在HandleSignInAsync方法中將當前時間賦值給該屬性。

這裡針對認證票據的有效期詳細說明一下:

通過上面我們已經得知,認證票據的有效期是通過AuthenticationProperties.ExpiresUtc來設定的,它是一個明確的時間點,如果我們沒有手動賦值給該屬性,那麼Cookie的認證處理器CookieAuthenticationHandler會將Cookie認證方案配置中的CookieAuthenticationOptions.ExpireTimeSpan + AuthenticationProperties.IssuedUtc的結果賦值給該屬性。

而我們又知道,在配置Cookie認證方案時,Cookie.Expiration屬性表示的是Cookie的Expires屬性,但是它被禁用了,如果強行使用它,我們會得到這樣一段選項驗證錯誤資訊:

Cookie.Expiration is ignored, use ExpireTimeSpan instead.

可是ExpireTimeSpan屬性,註釋明確地說它指的不是Cookie的Expires屬性,而是票據的有效期,這又是咋回事呢?其實,你可以想象一下以下場景:該Cookie的ExpiresMax-Age都沒有被設定(程式允許它們為空),那麼該Cookie的有效期就是當前會話,但是,你通過設定AuthenticationProperties.IsPersistent = true來表明該Cookie是持久化的,這就產生了歧義,實際上Cookie並沒有持久化,但是程式碼卻認為它持久化了。所以,為了解決這個歧義,Cookie.Expiration就被禁用了,而新增了一個ExpireTimeSpan屬性,它除了可以作為票據的有效期外,還能在Cookie的ExpiresMax-Age都沒有被設定但AuthenticationProperties.IsPersistent = true的情況下,將值設定為Cookie的Expires屬性,使得Cookie也被持久化。

我們看一下登入效果:

  • 未選擇“記住我”時:

  • 選擇“記住我”時:

其他的特性自己摸索一下吧!

下面是SignInAsync 的核心內部細節模擬,更多細節請檢視AuthenticationServiceCookieAuthenticationHandler

public class AccountController : Controller
{
    private readonly IOptionsMonitor<CookieAuthenticationOptions> _cookieAuthOptionsMonitor;

    public AccountController(IOptionsMonitor<CookieAuthenticationOptions> cookieAuthOptions)
    {
        _cookieAuthOptionsMonitor = cookieAuthOptions;
    }

    [HttpPost]
    public async Task<IActionResult> Login([FromForm] LoginViewModel input)
    {
        // ...
        
        var options = _cookieAuthOptionsMonitor.Get(CookieAuthenticationDefaults.AuthenticationScheme);
        var ticket = new AuthenticationTicket(principal, properties, CookieAuthenticationDefaults.AuthenticationScheme);
        // ticket加密
        var cookieValue = options.TicketDataFormat.Protect(ticket, GetTlsTokenBinding(HttpContext));

        // CookieOptions 就隨便 new 個了,其實應該將 options 和 ticket 的配置轉化為 CookieOptions
        options.CookieManager.AppendResponseCookie(HttpContext, options.Cookie.Name, cookieValue, new CookieOptions());

        // ...
    }
}

使用者登出

登出就比較簡單了,就是將Cookie清除,不再進行贅述:

[HttpPost]
public async Task<IActionResult> Logout()
{
    await HttpContext.SignOutAsync();

    return Redirect("/");
}

可以看到名為“auth”的Cookie已被清空:

至此,一個簡單的基於Cookie的身份認證功能就實現了。

授權(Authorization)

新增授權中介軟體

要使用授權,需要先通過UseAuthorization新增授權中介軟體——AuthorizationMiddleware

public class Startup
{
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Home/Error");
        }
        app.UseStaticFiles();
    
        app.UseRouting();
    
        // 身份認證中介軟體
        app.UseAuthentication();
        // 授權中介軟體
        app.UseAuthorization();
    
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=Index}/{id?}");
        });
    }
}

UseAuthorization一定要放到UseRoutingUseAuthentication之後,因為授權中介軟體需要用到Endpoint。另外,還要放到UseEndpoints之前,否則請求在到達Controller之前,不會執行授權中介軟體。

授權配置

現在,授權中介軟體已經加好了,現在需要在ConfigureServices方法中新增授權所需要用到的服務並進行額外配置。

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthorization(options =>
        {
            options.DefaultPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
            options.InvokeHandlersAfterFailure = true;
        });
    }
}
  • DefaultPolicy:預設的授權策略,預設為new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(),即通過身份認證的使用者才能獲得授權。
  • InvokeHandlersAfterFailure:當存在多個授權處理器時,若其中一個失敗後,後續的處理器是否還繼續執行。預設為true,即會繼續執行。

Url新增授權

現在,我們要求使用者登入後才可以訪問/Home/Privacy,為其新增特性[Authorize],不需要傳入策略policy,就用預設策略即可:

public class HomeController : Controller
{
    [HttpGet]
    [Authorize]
    public IActionResult Privacy()
    {
        return View();
    }
}

你可以嘗試在其中訪問HttpContext.User,它其實就是我們登入時建立的ClaimsPrincipal

全域性Cookie策略

另外,我們可以通過UseCookiePolicy針對Cookie策略進行全域性配置。需要注意的是,CookiePolicyMiddleware僅會對它之後新增的中介軟體起效,所以要儘量將它放在靠前的位置。

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // Cookie全域性策略
        services.AddCookiePolicy(options =>
        {
            options.OnAppendCookie = context =>
            {
                Console.WriteLine("------------------ On Append Cookie --------------------");
                Console.WriteLine($"Name: {context.CookieName}\tValue: {context.CookieValue}");
            };

            options.OnDeleteCookie = context =>
            {
                Console.WriteLine("------------------ On Delete Cookie --------------------");
                Console.WriteLine($"Name: {context.CookieName}");
            };
        });

        services.AddControllersWithViews();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Home/Error");
        }
        app.UseStaticFiles();

        app.UseRouting();

        // Cookie 策略中介軟體
        app.UseCookiePolicy();

        // 身份認證中介軟體
        app.UseAuthentication();
        // 授權中介軟體
        app.UseAuthorization();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=Index}/{id?}");
        });
    }
}

優化改進

優化Claim以減小身份認證Cookie體積

在使用者登入時,驗證通過後,會新增Claims,其中“型別”使用的是微軟提供的ClaimTypes

new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString("N")),
new Claim(ClaimTypes.Name, input.UserName)

細心地你會發現,ClaimTypes的值太長了:

public static class ClaimTypes
{
    public const string Name = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name";

    public const string NameIdentifier = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier";
}

我們可以使用JwtClaimTypes進行優化:

public static class JwtClaimTypes
{
    public const string Id = "id";
    
    public const string Name = "name";
}
  1. 安裝 IdentityModel 包
Install-Package IdentityModel
  1. 進行替換,注意要在建立ClaimsIdentity例項時指定NameRole的型別,這樣HttpContext.User.Identity.NameHttpContext.User.IsInRole(string role)才能正常使用:
var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme, JwtClaimTypes.Name, JwtClaimTypes.Role);
identity.AddClaims(new[]
{
    new Claim(JwtClaimTypes.Id, Guid.NewGuid().ToString("N")),
    new Claim(JwtClaimTypes.Name, input.UserName)
});

在服務端儲存Session資訊

或許,你還是認為Cookie體積太大了,而且隨著Cookie中儲存資訊的增加,還會越來越大,那你可以考慮將會話(Session)資訊儲存在服務端進行解決,這也在一定程度上對資料安全作了保護。

這個方案非常簡單,我們將會話資訊即認證票據儲存在服務端而不是Cookie,Cookie中只需要存放一個SessionId。當請求傳送到服務端時,會獲取到SessionId,通過它,就可以從服務端獲取到完整的Session資訊。

會話資訊的儲存介質多種多樣,可以是記憶體、也可以是分散式儲存中介軟體,如Redis等,接下來我就以記憶體為例進行介紹(Redis的方案可以在我的示例程式原始碼中找到,這裡就不貼了)。

CookieAuthenticationOptions中,有個SessionStore,型別為ITicketStore,用來定義會話的儲存,接下來我們就來實現它:

public class MemoryCacheTicketStore : ITicketStore
{
    private const string KeyPrefix = "AuthSessionStore-";
    private readonly IMemoryCache _cache;
    private readonly TimeSpan _defaultExpireTimeSpan;

    public MemoryCacheTicketStore(TimeSpan defaultExpireTimeSpan, MemoryCacheOptions options = null)
    {
        options ??= new MemoryCacheOptions();
        _cache = new MemoryCache(options);
        _defaultExpireTimeSpan = defaultExpireTimeSpan;
    }

    public async Task<string> StoreAsync(AuthenticationTicket ticket)
    {
        var guid = Guid.NewGuid();
        var key = KeyPrefix + guid.ToString("N");
        await RenewAsync(key, ticket);
        return key;
    }

    public Task RenewAsync(string key, AuthenticationTicket ticket)
    {
        var options = new MemoryCacheEntryOptions();
        var expiresUtc = ticket.Properties.ExpiresUtc;
        if (expiresUtc.HasValue)
        {
            options.SetAbsoluteExpiration(expiresUtc.Value);
        }
        else
        {
            options.SetSlidingExpiration(_defaultExpireTimeSpan);
        }

        _cache.Set(key, ticket, options);

        return Task.CompletedTask;
    }

    public Task<AuthenticationTicket> RetrieveAsync(string key)
    {
        _cache.TryGetValue(key, out AuthenticationTicket ticket);
        return Task.FromResult(ticket);
    }

    public Task RemoveAsync(string key)
    {
        _cache.Remove(key);
        return Task.CompletedTask;
    }
}

然後,只需要給CookieAuthenticationOptions.SessionStore賦值就好了:

options.SessionStore = new MemoryCacheTicketStore(options.ExpireTimeSpan);

以下是一個儲存在Cookie中的SessionId示例,雖然還是很長,但是它並不會隨著資訊量的增加而變大:

CfDJ8OGRqoEUgBZEu4m5Q8NfuATXjRKivKy7CR-oPpx2SaNJ8n1GWyBbPhNTEQzzIbZ62DqJPuxKtBJ752GqNxod9U5paaI_aQdH9EOH8nvgrinjvdHTneeKlhBvamEQrq7nA1e3wJOuQwFXRJASUphkS3kQzvc4-Upz27AAfoD510MC7YiwlhyxWl7agb8F0eeiilxAHDn4gskVqshu2hc5ENQAJNjXpa0yVaseryvsPrbukv5jqGC12WuUVe1cYhBIdWHHT61ZJcNtvNOAdtVlVA7i7RCJUBxNCUAhB-mw_s7R4GsNbU8aW7Ye9H-tx5067w

好文推薦

原始碼請戳XXTk.Auth.Samples.Cookies.Web

相關文章