The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

如何在 ASP.NET Core 6 使用 Token-based 身份認證與授權 (JWT)

ASP.NET Core 6 提供了一組 Minimal APIs 可以大幅簡化啟動 ASP.NET Core 應用程式的程式碼,但由於註冊服務到 DI 容器的 API 被簡化了,因此程式的寫法有些差異。今天這篇文章,我打算重新撰寫 如何在 ASP.NET Core 3 使用 Token-based 身分驗證與授權 (JWT) 這篇文章,改以 ASP.NET Core 6 Minimal APIs 來進行實作。

重點摘要

要在 ASP.NET Core 專案實現 Token-based 的身分驗證與授權,最簡單的方式就是透過 JWT 進行實作,整個實作的過程大致分成三個部分:

  1. 產生合法有效的 JWT Token
  2. 驗證合法有效的 JWT Token
  3. 限制特定 API 只能在通過 JWT 驗證的 HTTP 要求才能存取

本篇文章原本想嘗試用 Jwt.Net 來進行實作,但實作完成後才發現有許多缺陷,這套沒辦法處理複雜格式的 JWT Payload,它只能處理簡單的格式 (只有一層 JSON 物件的資料結構),他只能轉型成 Dictionary<string, string> 型別而已。如果我們要儲存多重角色的資訊在 JWT Token 中時,透過 Jwt.Net 目前版本還無法處理。不過,大部分 JWT Token 的 Payload 確實都只有簡單 Key-Value 而已,用 Dictionary<string, string> 就能處理。我實作的範例程式放在這裡:https://github.com/doggy8088/AspNetCore6JwtNetAuthN

初始化專案

本篇文章以 ASP.NET Core 6 為主,所以我們先建立一個最陽春的 Web API 專案:

  1. 先建立 global.json 檔案,將 .NET SDK 限定在 6.0.102 版本

    dotnet new globaljson --sdk-version 6.0.102
    
  2. 建立 JwtAuthDemo 專案

    dotnet new webapi -n JwtAuthDemo -minimal
    cd JwtAuthDemo
    
    # 加入 Git 版控
    dotnet new gitignore
    git init
    git add .
    git commit -m "Initial commit"
    
  3. 安裝 Microsoft.AspNetCore.Authentication.JwtBearer 套件

    dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
    
    # 加入 Git 版本
    git commit -m "dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer" -a
    
  4. 使用 VS Code 開啟專案

    code .
    

    請記得安裝我的 .NET Core Extension Pack 擴充套件

產生合法有效的 JWT Token

  1. 加入 JwtHelpers 輔助類別

    這部分我寫了一個 JwtHelpers.cs 檔案,裡面有個 GenerateToken() 方法,所有說明直接放在程式碼註解中。

  2. 加入 appsettings.json 組態設定

    上述範例程式由於需要讀取組態設定,因此我們要加入一些組態設定到 appsettings.json 檔案中。

    {
      "JwtSettings": {
        "Issuer": "JwtAuthDemo",
        "SignKey": "1Zl4h9703IzROikK3@uK&&OEb"
      },
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"
        }
      },
      "AllowedHosts": "*"
    }
    

    你也可以透過其他組態提供者提供該設定 (例如:環境變數命令列參數User Secrets、...)。

  3. JwtHelpers 類別註冊進 .NET 的 DI 容器中 (IServiceCollection)

    請寫在 var app = builder.Build(); 之前:

    builder.Services.AddSingleton<JwtHelpers>();
    
  4. 加入一個 LoginViewModel 模型類別 (用來登入時的模型繫結)

    record LoginViewModel(string Username, string Password);
    
  5. 加入身份認證與授權相關的 Minimal APIs 到專案中

    // 登入並取得 JWT Token
    app.MapPost("/signin", (LoginViewModel login, JwtHelpers jwt) =>
        {
            if (ValidateUser(login))
            {
                var token = jwt.GenerateToken(login.Username);
                return Results.Ok(new { token });
            }
            else
            {
                return Results.BadRequest();
            }
        })
        .WithName("SignIn")
        .AllowAnonymous();
    
    // 取得 JWT Token 中的所有 Claims
    app.MapGet("/claims", (ClaimsPrincipal user) =>
        {
            return Results.Ok(user.Claims.Select(p => new { p.Type, p.Value }));
        })
        .WithName("Claims")
        .RequireAuthorization();
    
    // 取得 JWT Token 中的使用者名稱
    app.MapGet("/username", (ClaimsPrincipal user) =>
        {
            return Results.Ok(user.Identity?.Name);
        })
        .WithName("Username")
        .RequireAuthorization();
    
    // 取得使用者是否擁有特定角色
    app.MapGet("/isInRole", (ClaimsPrincipal user, string name) =>
        {
            return Results.Ok(user.IsInRole(name));
        })
        .WithName("IsInRole")
        .RequireAuthorization();
    
    // 取得 JWT Token 中的 JWT ID
    app.MapGet("/jwtid", (ClaimsPrincipal user) =>
        {
            return Results.Ok(user.Claims.FirstOrDefault(p => p.Type == "jti")?.Value);
        })
        .WithName("JwtId")
        .RequireAuthorization();
    
    bool ValidateUser(LoginViewModel login)
    {
        return true;
    }
    
    record LoginViewModel(string Username, string Password);
    

目前的程式碼已經可以透過 Web API 取得 JWT Token,我們可以把網站跑起來之後,透過 cURL 進行測試:

  1. 啟動網站

    dotnet watch run
    
  2. 測試登入並取得 JWT Token

    PowerShell

    $body = '{"Username": "will", "Password": "123"}'
    $header = @{ "Content-Type"="application/json" }
    
    Invoke-RestMethod -Uri "https://localhost:7255/signin" -Method 'Post' -Body $body -Headers $header | ConvertTo-Json
    

    Bash

    curl -X POST https://localhost:7255/signin -H 'Content-Type: application/json' --data '{"Username": "will", "Password": "123"}'
    

    你應該會得到類似以下結果:

    {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ3aWxsIiwianRpIjoiZDRhNGNjNDYtZTI3MC00NDkyLTk0OTItNWU0OWE1MDJjYjYwIiwicm9sZXMiOlsiQWRtaW4iLCJVc2VycyJdLCJuYmYiOjE2NDQ2ODQ3NTAsImV4cCI6MTY0NDY4NjU1MCwiaWF0IjoxNjQ0Njg0NzUwLCJpc3MiOiJKd3RBdXRoRGVtbyJ9.eLw3ed6z6IPdWBbxNrd9W8Azd0oxkkXiVU-za6N3Gc8"
    }
    

驗證合法有效的 JWT Token

知道如何簽發 Token 之後,接下來就要讓你的 ASP.NET Core 能夠認得使用者傳入的 Bearer Token,這部分需要加入身份認證的服務宣告,也要加入 Middleware 設定。

  1. 加入 builder.Services 服務宣告部分

    這裡不但要加入 AddAuthentication(JwtBearerDefaults.AuthenticationScheme),也要加入 AddAuthorization()

    builder.Services
        .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            // 當驗證失敗時,回應標頭會包含 WWW-Authenticate 標頭,這裡會顯示失敗的詳細錯誤原因
            options.IncludeErrorDetails = true; // 預設值為 true,有時會特別關閉
    
            options.TokenValidationParameters = new TokenValidationParameters
            {
                // 透過這項宣告,就可以從 "sub" 取值並設定給 User.Identity.Name
                NameClaimType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
                // 透過這項宣告,就可以從 "roles" 取值,並可讓 [Authorize] 判斷角色
                RoleClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
    
                // 一般我們都會驗證 Issuer
                ValidateIssuer = true,
                ValidIssuer = builder.Configuration.GetValue<string>("JwtSettings:Issuer"),
    
                // 通常不太需要驗證 Audience
                ValidateAudience = false,
                //ValidAudience = "JwtAuthDemo", // 不驗證就不需要填寫
    
                // 一般我們都會驗證 Token 的有效期間
                ValidateLifetime = true,
    
                // 如果 Token 中包含 key 才需要驗證,一般都只有簽章而已
                ValidateIssuerSigningKey = false,
    
                // "1234567890123456" 應該從 IConfiguration 取得
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration.GetValue<string>("JwtSettings:SignKey")))
            };
        });
    
    builder.Services.AddAuthorization();
    
  2. 加入身份認證與授權的 Middleware 宣告

    請加入以下兩行在 app.UseHttpsRedirection(); 後面:

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

限制特定 API 只能在通過 JWT 驗證的 HTTP 要求才能存取

這部分我們的程式已經在文章稍早就寫好了,這裡特別列出啟用身份授權的程式碼。

  1. 取得 JWT Token 中的所有 Claims 資訊 (需登入才能使用)

    若要取得以前 ASP.NET Core 中常見的 User 物件,你必須注入一個 ClaimsPrincipal user 參數,透過這個傳入的物件就可以取得早期 User 屬性下的所有資訊。

    app.MapGet("/claims", (ClaimsPrincipal user) =>
        {
            return Results.Ok(user.Claims.Select(p => new { p.Type, p.Value }));
        })
        .WithName("Claims")
        .RequireAuthorization();
    

    程式碼最後一行的 .RequireAuthorization() 即代表該 Minimal API 必須通過授權才能執行,否則就會得到 HTTP 401 的回應。

  2. 取得 JWT Token 中的使用者名稱 (需登入才能使用)

    app.MapGet("/username", (ClaimsPrincipal user) =>
        {
            return Results.Ok(user.Identity?.Name);
        })
        .WithName("Username")
        .RequireAuthorization();
    
  3. 取得 JWT Token 中的 JWT ID (需登入才能使用)

    app.MapGet("/jwtid", (ClaimsPrincipal user) =>
        {
            return Results.Ok(user.Claims.FirstOrDefault(p => p.Type == "jti")?.Value);
        })
        .WithName("JwtId")
        .RequireAuthorization();
    
  4. 取得使用者是否擁有特定角色 (需登入才能使用)

    app.MapGet("/isInRole", (ClaimsPrincipal user, string name) =>
        {
            return Results.Ok(user.IsInRole(name));
        })
        .WithName("IsInRole")
        .RequireAuthorization();
    

用戶端在取得 JWT Token 之後該如何強制登出

基本上,所有 API 都是無狀態的,因此當用戶端取得 Token 之後,以 JWT 的機制來看,唯一的失效方式就是等到 Token 超過到期時間才行!

但是,在 RFC7519 JSON Web Token (JWT) 規格中,有要求每次發出的 JWT Token 中都必須包含一個 JWT ID (jti) 聲明(Claim),而且每個 JWT Token 都不能重複,必須是唯一值

因此,你還是可以透過 user.Claims 取得所有 Claims 聲明資訊,因此你可以透過以下程式碼取出當下 JWT Token 的 jti 聲明值:

var jwt_id = user.Claims.FirstOrDefault(p => p.Type == "jti").Value;

接著,你只要將這個尚未到期JWT Token 唯一碼加入到自訂的黑名單中,再透過一個篩選器(Filters)過濾掉黑名單中的 API 要求即可!

由於 JWT 的 exp 到期時間是 JWT 規格中強制要求檢查的項目,如果超過到期日的時候該 JWT Token 就會自動失效,因此你在加入「黑名單」的時候,應該也要連同時間一起寫入,並定期清理所有無效的 jti 清單,以免暫存的空間被佔滿。

相關連結