dotnet core shared cookie by redis

此範例是為了練習透過 docker 佈署,並於更版當下,利用 shared-cookie 避免網站的使用者被登出系統

首先我們先建立一個新的 demo 專案,環境為.netCore 2.1,為了方便示範,使用了預設的 MVC 範本,這邊我是透過 visual Studio 2019 建立的,似乎透過 cli 建立的範本會有點不同,但不影響示範

加入 identity

第一件事情就是將網站的登入機制建立起來

正常來說我們會讓使用者輸入帳號、密碼,並且經過後端驗證,一般會去資料庫查詢並回傳結果,如果沒問題的話,我們就會從資料庫中取得該會員的資料並設定在身份聲明中,這些聲明在網站的程式碼中可以隨時被調用

AccountController.cs

1
2
3
4
5
6
7
8
9
10
11
12
// LoginRequest 只是一個單純的 DTO 物件,只有 LoginId 與 Password 兩個屬性
public async Task<JsonResult> Login(LoginRequest form)
{
// 一開始都先將使用者登出系統
await AuthorizeManagement.SignOutAsync();
// 如果帳號密碼不符合,回傳前端 json 為 false
if (form.LoginId != "test" || form.Password != "1234") return Json(new {result = false});

// 符合的話就進行登入系統的行為,此處由我們自己撰寫的類別來處理登入這件事情
await AuthorizeManagement.SignInAsync(form.LoginId);
return Json(new {result = true});
}

AuthorizeManagement.cs

HttpContext 透過建構式注入取得,於 startup.cs 中設定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static async Task SignInAsync(string loginId)
{
// 身份聲明
ClaimsIdentity identity = new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, loginId),
},
CookieAuthenticationDefaults.AuthenticationScheme);

// 此處設定大概的意思是,如果15分鐘內使用者沒有跟網站互動的話,時間到就把客戶端的cookie資料清除 (藉由expires由瀏覽器處理)
AuthenticationProperties authProperties = new AuthenticationProperties
{
AllowRefresh = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(15),
IsPersistent = true,
};

await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
authProperties);
}

startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// startup.cs
public void ConfigureServices(IServiceCollection services)
{
//..略
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie()

services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
AuthorizeManagement.Configure(app.ApplicationServices.GetRequiredService<IHttpContextAccessor>());

if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseCookiePolicy();
app.UseAuthentication(); // 需要加入這一行

app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Account}/{action=Index}/{id?}");
});
}

other files

其他的檔案細節請自行參考 github

測試網站

首頁已經改成/Account/Index,所以一開網站應該就會看到下面這畫面

將開發者工具開啟,可以看到 cookie 是沒東西的

點選 Login 之後,看到 Cookie 就產生出來了,注意到 Expires 的時間,正好是我截圖時候再加 15 分鐘

Cookie 的時間是 GMT 時間

測試 docker images 佈署

登入機制看起來動作很正常,一般情況下也沒問題,但是 cookie 的產生與 machine key 有關,所以很可能在 load balance 環境、或者是透過 docker 佈署的環境下會有問題,因此我們先來測試一下,若是透過 docker 佈署、更版,網站使用者是否會被登出

dockerfile

1
2
3
4
5
6
7
8
9
FROM microsoft/dotnet:2.1-sdk AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /out

FROM microsoft/dotnet:2.1-aspnetcore-runtime AS runtime
WORKDIR /app
COPY --from=build /out .
ENTRYPOINT ["dotnet", "shared-cookie-redis.dll"]

建立 docker image

1
docker build -t demo .

建立 container

1
docker run -d --rm --name=mysite -p 7000:80 demo

測試步驟

先將網站透過 docker run 起來,並於網站登入,產生 cookie

將網站 container 停止,並重新 run 一個新的 container

瀏覽器直接重新整理,觀察 cookie 與頁面

所以我們可以合理的猜測,因為 cookie 加密 base on machine key,但是因為在 container 重新建立的情況下,machine key 不同,所以原先產生的 cookie 也隨著失效了

那麼,如果 machine key 是一樣的,是否就可以解決這個問題?

設定 asp.net core 資料保護

最多的資料大概就是官網了,這裡提到很多種方法,大致上講一下

就我理解的部分來說,整個 cookie 的產生很大一部分取決 machine key,他是一種資料保護的機制,原本 windows 主機上都會有這東西,時間到了它會自動產生一個,通常的路徑會是在%LOCALAPPDATA%\ASP.NET\DataProtection-Keys,如果我們要變更程式取得 machine key 的位置的話,可以透過PersistKeysToFileSystem來做,而你一但變更了這個位置,這個 key 就會是沒有就會是沒有加密過的,如果你還想要加密,那就要透過ProtectKeysWith,並給予一個符合 x.509 的憑證來加密資料,這裡有一個範例可以看一下

如果想要測試的話,可以直接用下面的方式就好,這樣子專案目錄下的 keys 在 runtime 的時候就會產生 key,此處可以將 key 的 expires 時間設置長一點,然後將這個產生出來的 key 加入到專案內,並且設定編譯時候的動作永遠複製,這樣就都會用同一個 key,但是因為我沒辦法驗證 expires 是否真的有效,加上一些因素,最終沒有在朝這個解決方案下去研究

1
services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo("keys"));

安裝 Redis 相關套件

  1. StackExchange.Redis
  2. Microsoft.AspNetCore.DataProtection.Redis

這版本很怪,.netCore 2.2 以上才能裝Microsoft.AspNetCore.DataProtection.StackExchangeRedis,但 2.1 以下的只能裝Microsoft.AspNetCore.DataProtection.Redis,在 Rider 的介面裡我又找不到,最後直接用 cli 下指令安裝

1
dotnet add package Microsoft.AspNetCore.DataProtection.Redis --version 0.4.1

接著在startup.cs,設定資料保護,當然 redis 要先啟動起來

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//startup.cs
public void ConfigureServices(IServiceCollection services)
{
// DI
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();

services.Configure<CookiePolicyOptions>(options =>
{
// This lambda determines whether user consent for non-essential cookies is needed for a given request.
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
});

var redisConnStr = "127.0.0.1,abortConnect=false,syncTimeout=10000";
var redis = ConnectionMultiplexer.Connect(redisConnStr);
services.AddDataProtection();
services.Configure<KeyManagementOptions>(o =>
{
o.XmlRepository = new RedisXmlRepository(() => redis.GetDatabase(), "DataProtection-Keys");
});

services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(option =>
{
option.ExpireTimeSpan = TimeSpan.FromMinutes(60);
});


services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
}

把網站跑起來,接著再去查詢 Redis 可以看到已經有 key 存入了

重新打包 image

網站停掉,原先的 image 刪除,重新再做一個 image

docker run 網站之後,進行登入並觀察 cookie 資訊

將 container 刪除再重新 run 一個

瀏覽器重新整理,查看是否為登入狀態

這邊要注意的事情是因為我們用了 redis,原先在程式內寫死127.0.0.1:6379如果包成 docker,會不能用。因為 docker container 要連 redis 應該要連 redis container 的 name,所以這邊為了 redis 的連線字串,將他放在Properties/lanuchSettings.jsonenvironmentVariables區段

並且在 docker image 打包的時候,透過ENV ASPNETCORE_ENVIRONMENT="Lab"去指定系統環境變數為Lab,然後,在專案Program.cs加了下面這一段,讓程式在 Lab 環境下可以讀取不同的連線字串供測試使用

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void Main(string[] args)
{
SetEnvironmentVariable();
CreateWebHostBuilder(args).Build().Run();
}
private static void SetEnvironmentVariable()
{
var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
if (env == "Lab")
{
Environment.SetEnvironmentVariable("redis", "myredis,abortConnect=false,syncTimeout=10000");
}
}

總結

至此,整個 POC 已經完成,相關程式碼已放置於 Github