ASP.NET Core 認證與授權[2]:Cookie認證

来源:http://www.cnblogs.com/RainingNight/archive/2017/09/28/cookie-authentication-in-asp-net-core.html
-Advertisement-
Play Games

由於HTTP協議是無狀態的,但對於認證來說,必然要通過一種機制來保存用戶狀態,而最常用,也最簡單的就是Cookie了,它由瀏覽器自動保存併在發送請求時自動附加到請求頭中。儘管在現代Web應用中,Cookie已略顯笨重,但它依然是最為重要的用戶身份保存方式。在 "上一章" 中整體的介紹了一下 ASP. ...


由於HTTP協議是無狀態的,但對於認證來說,必然要通過一種機制來保存用戶狀態,而最常用,也最簡單的就是Cookie了,它由瀏覽器自動保存併在發送請求時自動附加到請求頭中。儘管在現代Web應用中,Cookie已略顯笨重,但它依然是最為重要的用戶身份保存方式。在 上一章 中整體的介紹了一下 ASP.NET Core 中的認證流程,而未提及具體的實現方式,較為抽象,那本章就通過一個完整的示例,以及對其原理的解剖,來詳細介紹一下Cookie認證,希望能幫助大家對 ASP.NET Core 認證系統有一個更深入的瞭解。

目錄

  1. 示例
  2. 補充
  3. 源碼解析

示例

我們從零開始,一步一步來創建一個完整的 ASP.NET Core Cookie認證的詳細示例。

創建項目

我們首先創建一個空的 .NET Core 2.0 Web 項目:

create_project

在創建的空項目中,預設具有如下引用:

<ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.0" />
</ItemGroup>

Microsoft.AspNetCore.All 是 ASP.NET Core 的全家桶,包含 Mvc, EFCore, Identity, NodeService, AzureAppServe 等等,並集成到了 .NET Core SDK 當中,個人感覺略坑,有違 .NET Core 輕量模塊化的理念,雖然使用看似更加方便了,卻也讓我們變得更加傻瓜化。在本文中為了更好的演示,就移除掉 Microsoft.AspNetCore.All 的引用,然後手動安裝需要的Nuget包,當然,你可以不這麼做,並略過此小節。

在本章中,會用到以下幾個項目中的Nuget包:

  • HttpAbstractions, ASP.NET Core的核心項目,我們的每一個應用程式都需要引用該項目。
  • Hosting, ASP.NET Core應用程式的宿主程式,必須引用。
  • KestrelHttpServer, 最常用的Web伺服器,支持跨平臺,在Windows下還可以使用HttpSysServerIISIntegration
  • Logging,用來記錄日誌,可將日誌輸出到控制台,調試視窗,Windows事件日誌等。
  • Security,ASP.NET Core 認證系統,包括Cookies, JwtBearer, OAuth, OpenIdConnect等。

我們使用 DotNet CLI 來安裝Nuget包:

dotnet add package Microsoft.AspNetCore.Hosting --version 2.0.0
dotnet add package Microsoft.AspNetCore.Server.Kestrel --version 2.0.0
dotnet add package Microsoft.Extensions.Logging.Console --version 2.0.0
dotnet add package Microsoft.AspNetCore.Authentication.Cookies --version 2.0.0

由於WebHost.CreateDefaultBuilder包含在Microsoft.AspNetCore.All中,需要對Program做如下修改:

public static IWebHost BuildWebHost(string[] args) =>
        new WebHostBuilder()
        .UseKestrel()
        .UseUrls("http://localhost:5000")
        .UseContentRoot(Directory.GetCurrentDirectory())
        .ConfigureLogging((hostingContext, logging) =>
        {
            logging.AddConsole();
        })
        .UseStartup<Startup>()
        .Build();

如上,我們使用Kestrel伺服器,監聽5000埠,並將日誌列印到控制台,需要註意的是我們並沒有使用UseIISIntegration,因此不支持在IIS下運行,需要使用控制台的方式來運行,修改Properties/launchSettings.json文件:

{
  "profiles": {
    "Console": {
      "commandName": "Project",
      "launchBrowser": false,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

如上,將IIS的相關配置刪除掉,只保留控制台的啟動配置,然後在Starup文件的Configure方法中添加如下代碼:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.Run(async (context) =>
    {
        await context.Response.WriteAsync("Hello World!");
    });
}

最後按下F5,啟動程式,在瀏覽器中訪問訪問:http://localhost:5000/,輸出:

Hello World!

配置Cookie認證

在 ASP.NET Core 中,有一個非常重要的依賴註入系統,它貫穿於所有項目中。對於認證系統,同樣要先進行註冊:

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(options =>
    {
      options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    })
    .AddCookie(options =>
    {
        // 在這裡可以根據需要添加一些Cookie認證相關的配置,在本次示例中使用預設值就可以了。
    });
}

如上,我們只配置了DefaultScheme,這樣,DefaultSignInScheme, DefaultSignOutScheme, DefaultChallengeScheme, DefaultForbidScheme 等都會使用該 Scheme 作為預設值。

AddCookie 用來註冊 CookieAuthenticationHandler,由它來完成身份認證的主要邏輯。

在註冊完服務之後,接下來便是註冊中間件,在 ASP.NET Core 中都是這個套路:

public void Configure(IApplicationBuilder app)
{
    app.UseAuthentication();
}

如上,使用UseAuthentication方法註冊了AuthenticationMiddleware中間件,它會負責調用對應的Handler,在上一章中有詳細的介紹。

準備工作

既然是身份認證,那首先要有用戶,我們在這裡模擬一個用戶倉儲,用來實現用戶登錄時的用戶名和密碼的檢查。

定義用戶類:

public class User
{
    public int Id { get; set; }

    public string Name { get; set; }

    public string Email { get; set; }

    public string PhoneNumber { get; set; }

    public string Password { get; set; }

    public int Age { get; set; }
}

定義用戶倉儲:

public class UserStore
{
    private static List<User> _users = new List<User>() {
        new User {  Id=1, Name="alice", Password="alice", Email="[email protected]", PhoneNumber="18800000001" },
        new User {  Id=1, Name="bob", Password="bob", Email="[email protected]", PhoneNumber="18800000002" }
    };

    public User FindUser(string userName, string password)
    {
        return _users.FirstOrDefault(_ => _.Name == userName && _.Password == password);
    }
}

UserStore註冊到DI系統中:

public void ConfigureServices(IServiceCollection services)
{
  ...

  services.AddSingleton<UserStore>();
}

由於我們並沒有使用MVC,而使用字元串拼接的形式返回HTML較為費勁,在這裡定義幾個生成HTML的擴展方法:

public static class HttpResponseExtensions
{
    public static async Task WriteHtmlAsync(this HttpResponse response, Func<HttpResponse, Task> writeContent)
    {
        var bootstrap = "<link rel=\"stylesheet\" href=\"https://cdn.bootcss.com/bootstrap/3.3.7/css/bootstrap.min.css\" integrity=\"sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u\" crossorigin=\"anonymous\">";
        response.ContentType = "text/html";
        await response.WriteAsync($"<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"UTF-8\">{bootstrap}</head><body><div class=\"container\">");
        await writeContent(response);
        await response.WriteAsync("</div></body></html>");
    }
    public static async Task WriteTableHeader(this HttpResponse response, IEnumerable<string> columns, IEnumerable<IEnumerable<string>> data)
    {
        await response.WriteAsync("<table class=\"table table-condensed\">");
        await response.WriteAsync("<tr>");
        foreach (var column in columns)
        {
            await response.WriteAsync($"<th>{HtmlEncode(column)}</th>");
        }
        await response.WriteAsync("</tr>");
        foreach (var row in data)
        {
            await response.WriteAsync("<tr>");
            foreach (var column in row)
            {
                await response.WriteAsync($"<td>{HtmlEncode(column)}</td>");
            }
            await response.WriteAsync("</tr>");
        }
        await response.WriteAsync("</table>");
    }
    public static string HtmlEncode(string content) =>
        string.IsNullOrEmpty(content) ? string.Empty : HtmlEncoder.Default.Encode(content);
}

認證流程

接下來,便可以在我們的應用程式中愉快的使用認證系統了。在本文中只是最簡單的演示,便不使用MVC了,而是在Configure中通過中間件的形式來實現。

登錄

首先,我們定義一個登錄的頁面以及登錄成功後身體令牌的發放:

app.Map("/Account/Login", builder => builder.Run(async context =>
{
    if (context.Request.Method == "GET")
    {
        await context.Response.WriteHtmlAsync(async res =>
        {
            await res.WriteAsync($"<form method=\"post\">");
            await res.WriteAsync($"<input type=\"hidden\" name=\"returnUrl\" value=\"{HttpResponseExtensions.HtmlEncode(context.Request.Query["ReturnUrl"])}\"/>");
            await res.WriteAsync($"<div class=\"form-group\"><label>用戶名:<input type=\"text\" name=\"userName\" class=\"form-control\"></label></div>");
            await res.WriteAsync($"<div class=\"form-group\"><label>密碼:<input type=\"password\" name=\"password\" class=\"form-control\"></label></div>");
            await res.WriteAsync($"<button type=\"submit\" class=\"btn btn-default\">登錄</button>");
            await res.WriteAsync($"</form>");
        });
    }
    else
    {
        var userStore = context.RequestServices.GetService<UserStore>();
        var user = userStore.FindUser(context.Request.Form["userName"], context.Request.Form["password"]);
        if (user == null)
        {
            await context.Response.WriteHtmlAsync(async res =>
            {
                await res.WriteAsync($"<h1>用戶名或密碼錯誤。</h1>");
                await res.WriteAsync("<a class=\"btn btn-default\" href=\"/Account/Login\">返回</a>");
            });
        }
        else
        {
            var claimIdentity = new ClaimsIdentity("Cookie");
            claimIdentity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
            claimIdentity.AddClaim(new Claim(ClaimTypes.Name, user.Name));
            claimIdentity.AddClaim(new Claim(ClaimTypes.Email, user.Email));
            claimIdentity.AddClaim(new Claim(ClaimTypes.MobilePhone, user.PhoneNumber));
            claimIdentity.AddClaim(new Claim(ClaimTypes.DateOfBirth, user.Birthday.ToString()));

            var claimsPrincipal = new ClaimsPrincipal(claimIdentity);
            // 在上面註冊AddAuthentication時,指定了預設的Scheme,在這裡便可以不再指定Scheme。
            await context.SignInAsync(claimsPrincipal);
            if (string.IsNullOrEmpty(context.Request.Form["ReturnUrl"])) context.Response.Redirect("/");
            else context.Response.Redirect(context.Request.Form["ReturnUrl"]);
        }
    }
}));

如上,我們在Get請求中返回登錄頁面,在Post請求中驗證用戶名密碼,匹配成功後,創建用戶Claim, ClaimsIdentity, ClaimsPrincipal 最終通過SignInAsync方法將用戶身份寫入到響應Cookie中,完成身份令牌的發放。

授權

我們在登錄中間件後面添加一個自定義的授權中間件,用來禁用匿名用戶的訪問:

app.UseAuthorize();

UseAuthorize的實現很簡單,就是判斷用戶是否已通過認證,並跳過對首頁的驗證:

public static IApplicationBuilder UseAuthorize(this IApplicationBuilder app)
{
    return app.Use(async (context, next) =>
    {
        if (context.Request.Path == "/")
        {
            await next();
        }
        else
        {
            var user = context.User;
            if (user?.Identity?.IsAuthenticated ?? false)
            {
                await next();
            }
            else
            {
                await context.ChallengeAsync();
            }
        }
    });
}

其實上面的實現和我們在MVC5中常用的[Authorize]特性非常相似。

個人信息

再定義一個認證後才能訪問的頁面,並把當前登錄用戶的信息展示出來:

app.Map("/profile", builder => builder.Run(async context =>
{
    await context.Response.WriteHtmlAsync(async res =>
    {
        await res.WriteAsync($"<h1>你好,當前登錄用戶: {HttpResponseExtensions.HtmlEncode(context.User.Identity.Name)}</h1>");
        await res.WriteAsync("<a class=\"btn btn-default\" href=\"/Account/Logout\">退出</a>");
        await res.WriteAsync($"<h2>AuthenticationType:{context.User.Identity.AuthenticationType}</h2>");

        await res.WriteAsync("<h2>Claims:</h2>");
        await res.WriteTableHeader(new string[] { "Claim Type", "Value" }, 
            context.User.Claims.Select(c => new string[] { c.Type, c.Value }));
    });
}));

退出

退出則直接調用SignOutAsync方法即可:

app.Map("/Account/Logout", builder => builder.Run(async context =>
{
    await context.SignOutAsync();
    context.Response.Redirect("/");
}));

首頁

最後,添加一個簡單的首頁,方便測試:

app.Run(async context =>
{
    await context.Response.WriteHtmlAsync(async res =>
    {
        await res.WriteAsync($"<h2>Hello Cookie Authentication</h2>");
        await res.WriteAsync("<a class=\"btn btn-default\" href=\"/profile\">我的信息</a>");
    });
});

運行

在瀏覽器中打開http://localhost:5000/,顯示 "Hello Cookie Authentication",點擊 “我的信息” 按鈕:

請求:
GET http://localhost:5000/profile HTTP/1.1
Host: localhost:5000

響應:
HTTP/1.1 302 Found
Location: http://localhost:5000/Account/Login?ReturnUrl=%2Fprofile

因為我們沒有登錄,會在授權中間件中會執行context.ChallengeAsync();方法,最終會跳轉到登錄頁面,然後輸入用戶名密碼(alice/alice),登錄成功:

請求:
POST http://localhost:5000/Account/Login?ReturnUrl=%2Fprofile HTTP/1.1
Host: localhost:5000
Content-Type: application/x-www-form-urlencoded

returnUrl=%2Fprofile&userName=alice&password=alice

響應:
HTTP/1.1 302 Found
Location: /profile
Set-Cookie: .AspNetCore.Cookies=CfDJ8B4XRZETkRhMt3mT9VduB8KTzFhbcuszdNDXZpaQI3zSOcOD8uAzjr-iHzNCPVgXrKqxfK-MrP5d5r9X1zfKOgg2_j54t0ccAQ5nshSmXnRvjIZ6id3GD5fDP9v2x1iV0JE7X9IdoA458DZjx6qm6971GeY5HYVnT7odwgQR8eRaHo0-Wacmt95QuC9IVSapqsShHOeu5ZowFmDAPXrlUHOSwBPAjiLkf8mNbu8U4ZcWFlaBXC9-H-2_ts5wyi-90zw6jGxX3o7tRiQB4qq8IDmIJbZtN4Nl8TKHHcTbyFl5Z__MrgrjJ7s4cGdnIoDJWB9ENw1IGRgF3Rib8KmhkwhlUyO2VMnuVI8vSP2PcwrkUGtudJwHMHrA8cuS021xpmIhkhgW3e82r_0_jxAh1nqG4zwTP5i8iLU6FsOLLWatveSWB441Ntqw-L-pYczsBAYFRT0Hh56ofUAxGd7aaGtDx0jvuuxW5gK245Pf0TKG-4G46yDwLrFtjNcN_GREbpwtHAz-I7XqiDZgS3nbzjik5s05NxB7d6X3aOFc5JHCwFxW-i-xW-ToJLZrp3Jo8W0bAxVwxZIW2PwZlVtyeYSkqByFRaDS4qcBywE2Bmar_TyJm9UpVWaL2s9KxpU_DHN6meYne5E5dH4-k1DoABl6FyNPn6xYfMWxzu0_7ZFhVJjCycScy1jggCv4Hk5nkltj9A3QrFpNb_HCk21Uek9g-7Zi150EKfDzhGjMto5_hbWcmQtUsHuLbZlnYTHXZ-7zELZOepAUts2ZGoUnEaI; path=/; samesite=lax; httponly

可以看到,響應報文在Cookie中附加了身份令牌,並會跳轉到之前未登錄時訪問的頁面/profile,跳轉後顯示如下:

cookie_profile

如上,因為瀏覽器會自動附帶上剛纔寫入的Cookie,所以授權通過,並展示出我們在登錄時設置的Claim。

最後,點擊 “退出” ,響應報文中會將Cookie設置為空(清除Cookie):

請求:
GET http://localhost:5000/Account/Logout HTTP/1.1
Host: localhost:5000

響應:
HTTP/1.1 302 Found
Location: /
Set-Cookie: .AspNetCore.Cookies=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax

補充

JwtClaimTypes

在上面的演示中,我們可以看到Cookie中的值非常的長,而我們設置的Claim並不多,這是因為微軟內置的ClaimTypes都是一大串的ULR地址。而對於 ASP.NET Core 本身來說,它並不關心你使用的ClaimType是什麼,只要你讀取與保存時使用的ClaimType保持一致就沒有問題。我們可以使用簡短的字元串name來代替ClaimTypes.Name,但是推薦的做法是直接使用JwtClaimTypes,因為它夠簡短而且通用。

首先添加ClaimTypes的Package引用:

dotnet add package IdentityModel --version 2.12.0

然後,將之前的添加Claim的代碼修改如下:

var claimIdentity = new ClaimsIdentity("Cookie", JwtClaimTypes.Name, JwtClaimTypes.Role);
claimIdentity.AddClaim(new Claim(JwtClaimTypes.Id, user.Id.ToString()));
claimIdentity.AddClaim(new Claim(JwtClaimTypes.Name, user.Name));
claimIdentity.AddClaim(new Claim(JwtClaimTypes.Email, user.Email));
claimIdentity.AddClaim(new Claim(JwtClaimTypes.PhoneNumber, user.PhoneNumber));
claimIdentity.AddClaim(new Claim(JwtClaimTypes.BirthDate, user.Birthday.ToString()));

需要註意的是在創建ClaimsIdentity時需要手動指定它的NameType和RoleType,否則它將會使用預設的ClaimTypes.Name和ClaimTypes.Role,這樣會導致我們從ClaimsPrincipal中獲取Identity.Name屬性和執行IsInRole檢查時失敗。

運行,重新登錄,看看效果如何:

請求:
POST http://localhost:5000/Account/Login?ReturnUrl=%2Fprofile HTTP/1.1
Host: localhost:5000
Content-Type: application/x-www-form-urlencoded

returnUrl=%2Fprofile&userName=alice&password=alice

響應:
HTTP/1.1 302 Found
Date: Sun, 24 Sep 2017 06:04:28 GMT
Location: /profile
Set-Cookie: .AspNetCore.Cookies=CfDJ8B4XRZETkRhMt3mT9VduB8JV9NE2zJ9YVQmTpAU3E9Op9rQHvJ7WvdcrarbTGWE7c_e2aLpoZCdDJ7-0fTFZGUwuLVMC0vD_eeE9ct2Vj7gHCPCVeK3qQPsQ2lNmKvPwPf82-CURFXGgFC1y-N17tXdT7RoZhLHskIHx7qNcxeicS7wiSDhQD3l3mgOgq0bdjWJTk3LnpHk8zS0fDhKp6Vd6vFvCyzzRJu1ax5Y27Bg3dZp4Zsa3I9HAp5wXmyp51de8scS25nyaV0FEd1YUWgC1LsuwOODrSPqMkokv7XQXQc8W212O2dHbuuJ1xYEr1i5_Gl1syIX3ZuPj1_wqcnAKu5keY0ZVJz45iGYIRC09hd4n8j1SEA8dDlhbslCtyZ6xMt6MdRFv1D7fhbt_g4RGDk7ZkjpnT6z9q3dTWNzkS3gSd9AekBNbUNw9ojZmTWoCFhZgxz-6Wwtcp9z7vIo; path=/; samesite=lax; httponly

是不是短了很多?好吧,其實感覺還是有點長,沒關係,下麵再介紹一種更加徹底的優化方式。

而最終頁面上展示的Claims信息如下:

cookie_jwt_profile

SessionStore

終極的解決方案就是參考Session的原理,把Claims信息則保存在服務端,併為其設置一個ID,Cookie中則只保存該ID,這樣就可以在服務端通過該ID來檢索出完整的Claims信息。不過註意,這並不是在使用 ASP.NET Core 中的Session,只是參考其存儲方式。

那麼怎麼做呢?在前面註冊Cookie認證時,使用的AddCookie方法中,其CookieAuthenticationOptions參數還可以設置一個ITicketStore類型的SessionStore屬性,我們可以通過實現該介面來自定義Cookie的存取方式,在這裡,使用本地緩存來實現:

首先添加Microsoft.Extensions.Caching.Memory的Package引用:

dotnet add package Microsoft.Extensions.Caching.Memory --version 2.0.0

然後,定義MemoryCacheTicketStore類:

public class MemoryCacheTicketStore : ITicketStore
{
    private const string KeyPrefix = "CSS-";
    private IMemoryCache _cache;

    public MemoryCacheTicketStore()
    {
        _cache = new MemoryCache(new MemoryCacheOptions());
    }

    public async Task<string> StoreAsync(AuthenticationTicket ticket)
    {
        var key = KeyPrefix + Guid.NewGuid().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);
        }
        options.SetSlidingExpiration(TimeSpan.FromHours(1));
        _cache.Set(key, ticket, options);
        return Task.FromResult(0);
    }

    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.FromResult(0);
    }
}

MemoryCacheTicketStore配置到CookieAuthenticationOptions中:

.AddCookie(options =>
{
    options.SessionStore = new MemoryCacheTicketStore();
});

再次重新登錄,響應如下:

HTTP/1.1 302 Found
Location: /profile
Set-Cookie: .AspNetCore.Cookies=CfDJ8B4XRZETkRhMt3mT9VduB8JOI85seEY347RswRzSiL_BQlJTb4JeFqpJzXNW8xOH1CwUKjwsx4CJWyMV5Wwq61IV0Kz4If0LmmJpEicZi2uxmyE2jcCXw_IRaPOaP0eJYM-DkpsjlA_Qu9knFxrpGQaI_BuRbUbbVhy62V5vjwMzoSewmQiPblS1PbPiqXfjAGmF_ZaSM40kwNOboAP_SMoJjX0AtEzmsUqECWFPZLxLoOJJ10Kz16cnSjtxha_KXY7i8f95jVbnX3cj79-GQ5iXnRePBBR_2LsXI5eDW_6E; path=/; samesite=lax; httponly

這樣,Cookie中的值就非常簡短了(由於其還包含AuthenticationProperties序列化後的值,並沒有想象中的短),並且Cookie中的值不會再隨著我們設置的Claims的增加而變長,在分散式環境下則可以使用分散式緩存來保存。

Reacting to back-end changes

對於認證系統,身份令牌都會有一個有效期的概念,而Cookie認證中預設有效期是14天,因此只要瀏覽器沒有清除Cookie,並且Cookie沒有過期,便麽就一直可以驗證通過。但是,如果用戶修改了密碼,我們希望該Cookie失效,或者是用戶更新了Claims的信息時,我們希望重新生成Cookie,否則我們取到的還是舊的Claims信息。那麼,該怎麼做呢?

對此,網上比較流行的做法是在用戶資料庫中添加一個安全欄位,當用戶修改了一些安全性信息時,便更新該欄位,併在Claim中加入此欄位,一起寫入到Cookie中,驗證時便可以判斷該欄位是否與資料庫一致,若不一致則驗證失敗或重新生成:

public static class LastChangedValidator
{
    public static async Task ValidateAsync(CookieValidatePrincipalContext context)
    {
        var userRepository = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
        var userPrincipal = context.Principal;
        string lastChanged = (from c in userPrincipal.Claims where c.Type == "LastUpdated" select c.Value).FirstOrDefault();
        if (string.IsNullOrEmpty(lastChanged) || !userRepository.ValidateLastChanged(userPrincipal, lastChanged))
        {
            // 1. 驗證失敗 等同於 Principal = principal;
            context.RejectPrincipal();

            // 2. 驗證通過,並會重新生成Cookie。
            context.ShouldRenew();
        }
    }
}

如上,1 和 2 兩種方式,我們可以根據實際情況選擇一種,而不應該同時存在。

在Cookie認證的配置中,提供了一系列的事件,其中便有一個OnValidatePrincipal事件,用來附加服務端的驗證邏輯:

.AddCookie(options =>
{
    options.Events = new CookieAuthenticationEvents
    {
        OnValidatePrincipal = LastChangedValidator.ValidateAsync
    };
});

如上,便完成了該事件的註冊,不過該驗證通常會查詢資料庫,損耗較大,可以通過設置驗證周期來提高性能,如:每5分鐘執行驗證一次(在MVC5中是有該配置的,Core中暫未發現)。

Persistent and ExpiresUtc

對於Cookie來說,預設的過期時間為Session,即關閉瀏覽器後就清除。通常在用戶登錄時會提供一個記住我的選項,用來保證在關閉瀏覽時不清除Cookie。而在SignInAsync方法中,還接收一個AuthenticationProperties類型的參數,可以用來指定Cookie是否持久化以及過期時間:

await HttpContext.SignInAsync("MyCookieAuthenticationScheme", principal, new AuthenticationProperties
{
    // 持久保存
    IsPersistent = true

    // 指定過期時間
    ExpiresUtc = DateTime.UtcNow.AddMinutes(20)
});

看一下CookieAuthenticationHandler中SignInAsync方法關於該配置的實現:

if (!signInContext.Properties.ExpiresUtc.HasValue)
{
    signInContext.Properties.ExpiresUtc = issuedUtc.Add(Options.ExpireTimeSpan);
}
if (signInContext.Properties.IsPersistent)
{
    var expiresUtc = signInContext.Properties.ExpiresUtc ?? issuedUtc.Add(Options.ExpireTimeSpan);
    signInContext.CookieOptions.Expires = expiresUtc.ToUniversalTime();
}

只有在IsPersistent為True時,才會在寫入Cookie指定Expires。需要註意的是瀏覽器中的Cookie過期時間僅僅是用來指定瀏覽器是否刪除Cookie,而在Cookie存儲的值中,也會包含該Cookie認證的發佈時間和過期時間等,併在HandleAuthenticateAsync方法中對會其進行驗證,並不是說只要你有Cookie就能驗證通過。

源碼解析

AddCookie

AddCookie已多次用過,無需多說,直接看源碼:

public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder)
    => builder.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, null, null);

public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<CookieAuthenticationOptions> configureOptions)
{
    builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<CookieAuthenticationOptions>, PostConfigureCookieAuthenticationOptions>());
    return builder.AddScheme<CookieAuthenticationOptions, CookieAuthenticationHandler>(authenticationScheme, displayName, configureOptions);
}

其實現非常簡單,首先註冊了Cookie認證的配置項CookieAuthenticationOptions,而authenticationScheme參數用來指定當前認證的唯一的標識,不能重覆。通常,使用預設的CookieAuthenticationDefaults.AuthenticationScheme就可以了,但是當我們同時使用多個Cookie認證方式時,需要手動為他們指定不同的Scheme。

最後,直接調用上一章中介紹的AddScheme,完成對CookieAuthenticationHandler的註冊。

CookieAuthenticationOptions

CookieAuthenticationOptions是針對Cookie認證的各種配置,如重定向地址,認證階段事件的註冊,Cookie名,過期時間等等,首先看一下它的定義:

public class CookieAuthenticationOptions : AuthenticationSchemeOptions
{
    private CookieBuilder _cookieBuilder = new RequestPathBaseCookieBuilder
    {
        SameSite = SameSiteMode.Lax,
        HttpOnly = true,
        SecurePolicy = CookieSecurePolicy.SameAsRequest,
    };
    public CookieAuthenticationOptions()
    {
        ExpireTimeSpan = TimeSpan.FromDays(14);
        ReturnUrlParameter = CookieAuthenticationDefaults.ReturnUrlParameter;
        SlidingExpiration = true;
        Events = new CookieAuthenticationEvents();
    }
    public CookieBuilder Cookie
    {
        get => _cookieBuilder;
        set => _cookieBuilder = value ?? throw new ArgumentNullException(nameof(value));
    }
    public new CookieAuthenticationEvents Events
    {
        get => (CookieAuthenticationEvents)base.Events;
        set => base.Events = value;
    }
    public ITicketStore SessionStore { get; set; }
    // 當用戶未登錄時,重定向到該路徑,預設:/Account/Login
    public PathString LoginPath { get; set; }
    // 指定登出的路徑,預設:/Account/Logout
    public PathString LogoutPath { get; set; }
    // 當用戶無權訪問時,重定向到該路徑,預設:/Account/AccessDenied
    public PathString AccessDeniedPath { get; set; }
    // 返回地址參數名,預設:ReturnUrl
    public string ReturnUrlParameter { get; set; }
    // 指定Cookie的過期時間
    public TimeSpan ExpireTimeSpan { get; set; }   
    // 當Cookie過期時間已達一半時,是否重置為ExpireTimeSpan
    public bool SlidingExpiration { get; set; }
    // 用來將Cookie寫入到瀏覽器或刪除
    public ICookieManager CookieManager { get; set; }
    public IDataProtectionProvider DataProtectionProvider { get; set; }
    public ISecureDataFormat<AuthenticationTicket> TicketDataFormat { get; set; }
}

CookieBuilder

在 ASP.NET Core 2.0 中對針對Cookie的配置集中放到CookieBuilder類型當中,相比之前更加清晰:

public class CookieBuilder : object
{
    public virtual string Name { get; set; }
    public virtual string Path { get; set; }
    public virtual string Domain { get; set; }
    public virtual bool HttpOnly { get; set; }
    public virtual SameSiteMode SameSite { get; set; }
    public virtual CookieSecurePolicy SecurePolicy { get; set; }
    public virtual TimeSpan? Expiration { get; set; }
    public virtual TimeSpan? MaxAge { get; set; }
    public CookieOptions Build(HttpContext context);
}

都是一些針對Cookie配置的標準用法,無需多說。

CookieAuthenticationEvents

CookieAuthenticationEvents為我們提供了在Cookie認證的各個階段(如,登錄前後,退出前後,重定向等)註冊事件的機會,以便我們攔截一些預設行為,來自定義處理邏輯。

public class CookieAuthenticationEvents
{
    public virtual Task ValidatePrincipal(CookieValidatePrincipalContext context) => OnValidatePrincipal(context);
    public virtual Task SigningIn(CookieSigningInContext context) => OnSigningIn(context);
    public virtual Task SignedIn(CookieSignedInContext context) => OnSignedIn(context);
    public virtual Task SigningOut(CookieSigningOutContext context) => OnSigningOut(context);
    public virtual Task RedirectToLogout(RedirectContext<CookieAuthenticationOptions> context) => OnRedirectToLogout(context);
    public virtual Task RedirectToLogin(RedirectContext<CookieAuthenticationOptions> context) => OnRedirectToLogin(context);
    public virtual Task RedirectToReturnUrl(RedirectContext<CookieAuthenticationOptions> context) => OnRedirectToReturnUrl(context);
    public virtual Task RedirectToAccessDenied(RedirectContext<CookieAuthenticationOptions> context) => OnRedirectToAccessDenied(context);
}      

每一個事件都有它的預設實現,這裡就不再多說,我們可以根據實際情況進行註冊。

CookieAuthenticationHandler

CookieAuthenticationHandler便是Cookie認證的具體實現:

public class CookieAuthenticationHandler : AuthenticationHandler<CookieAuthenticationOptions>, IAuthenticationSignInHandler, IAuthenticationSignOutHandler
{
    ...

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var result = await EnsureCookieTicket();
        if (!result.Succeeded)
        {
            return result;
        }
        var context = new CookieValidatePrincipalContext(Context, Scheme, Options, result.Ticket);

        // 執行前而介紹的服務端驗證
        await Events.ValidatePrincipal(context);

        if (context.ShouldRenew)
        {
            // 重新生成Cookie
            RequestRefresh(result.Ticket);
        }
        return AuthenticateResult.Success(new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name));
    }

    public async virtual Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties properties)
    {
        ...

        var ticket = new AuthenticationTicket(signInContext.Principal, signInContext.Properties, signInContext.Scheme.Name);

        ....

        var cookieValue = Options.TicketDataFormat.Protect(ticket, GetTlsTokenBinding());
        Options.CookieManager.AppendResponseCookie(Context, Options.Cookie.Name, cookieValue, signInContext.CookieOptions);
        var signedInContext = new CookieSignedInContext(Context, Scheme, signInContext.Principal, signInContext.Properties, Options);
        await Events.SignedIn(signedInContext);
        var shouldRedirect = Options.LoginPath.HasValue && OriginalPath == Options.LoginPath;
        await ApplyHeaders(shouldRedirect, signedInContext.Properties);
        Logger.SignedIn(Scheme.Name);
    }
}

其核心方法HandleAuthenticateAsync會檢查請求Cookie,查找與CookieBuilder.Name對應的Cookie值,解密反序列化成AuthenticationTicket對象,最後在上一章介紹的AuthenticationMiddleware中間件中將Principal賦予給HttpContext

而CookieAuthenticationHandler還實現了IAuthenticationSignInHandlerIAuthenticationSignOutHandler,這也是ASP.NET Core中內置的唯一支持登錄和退出的認證方式。在SignInAsync方法中使用ClaimsPrincipal來創建一個AuthenticationTicket對象,然後將其加密,寫入到Cookie中,便完成了登錄(身份令牌的發放),而SignOutAsync方法則只是簡單的刪除Cookie。

篇幅有限,就不再多說,感興趣的可以去看一下完整代碼:CookieAuthenticationHandler

總結

Cookie認證是一種本地認證方式,也是最為簡單,最為常用的認證方式。其認證邏輯也很簡單,總結一下就是獲取請求中指定的Cookie,解密成功後,反序列生成 AuthenticationTicket 對象,併進行一系列的驗證,而登錄方法與之對應:根據用戶信息創建 AuthenticationTicket 對象,並加密後序列化,寫入到Cookie中。在下一章中,就來介紹一下最為流行的遠程認證方式:OAuthOpenID Connect

最後附上本文中的示例代碼:https://github.com/RainingNight/AspNetCoreSample/tree/master/src/CookieSample


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 創建LVM分區: 相關命令: pvcreat /dev/sdb{1,2,3} 創建物理捲 vgcreat test_vg1 /dev/sdb1 創建捲組 vgcreat test_vg2 -s 16M /dev/sdb2 /dev/sdb3 指定PE大小為16M lvcreat 在已存在的捲組中創建 ...
  • 自己挖了一個大坑,,,然後苦逼的在碼代碼重寫樣式! 廢物不多少 直接上代碼 先在前臺創建一個TextBox,然後各種附加的屬性加上去:如圖所示 效果圖: 樣式代碼: 本文原創出處:http://www.cnblogs.com/PettyHandSome/ 歡迎各位轉載,但是未經作者本人同意,轉載文章 ...
  • 1. foreach C#編譯器會把foreach語句轉換為IEnumerable介面的方法和屬性。 foreach語句會解析為下麵的代碼段。 調用GetEnumerator()方法,獲得數組的一個枚舉 在while迴圈中,只要MoveNext()返回true,就一直迴圈下去 用Current屬性訪 ...
  • 用socket做了個程式,本地測試沒有問題,發佈到伺服器上時連接不上,用telnet測試連接失敗 伺服器上netstat -a 查看埠情況,127.0.0.1綁定埠9300處於監聽狀態,如下圖: 修改socket綁定IP為伺服器IP,埠狀態變為下圖: telnet連接測試成功! 很納悶,查了下 ...
  • 問題的產生的背景 由於我們使用了jenkins進行部署(jenkins~集群分發功能和職責處理),而對於.net core項目來說又是跨平臺的,所以對它的項目拉取,包的還原,項目的編譯和項目的發佈都是在一臺linux的jenkins節點上進行的,而我們開發時是在windows系統,所以在進行還原和編 ...
  • Tips 原文作者:Thomas Anderson, Blinkist 原文地址:Steve Jobs was successful because he mastered 'deep work' — here's how you can, too 你喝過麥卡倫姆威士忌嗎?如果你有這樣的經歷,你一定 ...
  • 出於安全考慮,在後臺與前臺進行數據傳輸時,往往不會直接傳輸實體模型,而是使用Dto(Data transfer object 數據傳輸對象),這樣在後臺往前臺傳遞數據時可以省略不必要的信息,只保留必要的信息,大大增強數據安全性。 下麵給出兩個相互對應的關係模型User、UserDto public ...
  • 使用EntityFramework Code First開發,數據遷移是一個不得不提的技術。 在我們的開發過程中,難免需要對模型進行改進,模型改進後,會導致實體集與資料庫不一致,當然我們可以通過刪除資料庫然後再重構資料庫,但是在生產環境中這樣做,這樣或多或少會出現一些問題。使用“數據遷移”,可以幫助 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...