2022年12月14日 星期三

[React & .Net Core]API驗證機制實作說明

 

這篇主要是說明如何將API加入驗證機制,範例程式碼可參考這裡


首先說明一下API的驗證機制是如何實現的,

目前驗證機制比較常用的有兩種,分別是Session-Based AuthenticationToken-Based Authentication

(1)Session-Based Authentication


Session-Based Authentication,顧名思義就是利用 Session 來進行驗證的機制,整體流程如上圖所示。客戶端先將帳號密碼丟給伺服器端,伺服器端透過帳密驗證,確定是正確的使用者登入,就開始建立一個伺服器端與你之間連線的 Session(會期)資料,裡面會放置一些用來辨識這個 Session 是被哪個使用者建立的資料,接著會回傳該 Session 的 ID 回來,並要求客戶端未來要驗證時需要帶這個 Session 的 ID 在 HTTP Cookie 上。
這個 HTTP Cookie 是位於 HTTP request 的 header 中的一個小區塊,它會自動被存在客戶端(通常是瀏覽器)上,並且 Cookie 會記載該資料是來自於哪個網址的資料,在瀏覽器對該網址為基底的網址做 HTTP request 的時候,都會自動帶上這個 Cookie ,藉以讓伺服器能夠驗證身份,並透過伺服器端儲存的 Session 資料,去回傳相對應身份可以得到的資料,這一整個就是基本的 Session-Based Authentication 架構。

(2)Token-Based Authentication


Token-Based Authentication,顧名思義就是利用 Token 來進行驗證的機制,整體流程如上圖所示。基本上流程和 Session-Based Authentication 類似,但是伺服器端不需要另外儲存任何資料,而是把相關的資料透過密鑰簽署組合成 Token,交由客戶端保管。客戶端拿到 Token 後,只要在每次 HTTP request 發送的時候,在 header 中的 Authorization 區塊帶上 Token 給伺服器端,即可被伺服器端再次透過密鑰驗證是否 Token 沒有被修改過,進而從中抽取資料,了解是哪個使用者的請求,給予相對應身份可以得到的資料。

在 Token-Based Authentication 中,目前最常見的方式是用 JWT(JSON Web Token)去做為 Token 的格式,JWT 會將 Token 分成三段,分別是 Header、Payload 和 Signature,而其中 Payload 就是上述所說到會放置能夠辨識使用者資料所儲存的地方。另外要注意的是,JWT 所使用的 Token 只會使用密鑰進行簽署的動作,簽署完後形成的就是 Signature,伺服器在收到 Token 時僅使用密鑰去進行 Signature 驗證,確定該 Token 是否有被修改過,並不會對 Token 進行加密。如果有要對 Token 進行加密的話,可以選擇 JWE(JSON Web Encryption)的加密方式。

優缺點比較

兩種不同的 Authentication 皆有一些不同的優缺點,底下就列舉幾點讓大家了解一下:

  1. Session-Based Authentication 由於是利用 Cookie 傳遞資料,Cookie 只要是在同網域下的請求皆會帶上,故只要別人用它的網站去連結你 API 的網址(例如:刪除題目的 API 網址),就可以讓你不知不覺對你的網站進行資料庫操作的動作,這個攻擊稱作 CSRF(Cross Site Request Forgery)。最簡單的防止方式通常是將 SameSite 加註在 Cookie 上,讓瀏覽器僅會在發送的請求是與該網頁同網域的 URI 的時候,才會帶上 Cookie 在請求中。關於詳細的攻擊內容以及防止方式可見此文章
  2. Token-Based Authentication 的 Token 通常必須要由客戶端自行決定如何儲存。由於最常見的客戶端通常為瀏覽器,所以通常是使用 JavaScript 去進行儲存。那如果在你的網頁上可以被別人加上 JavaScript 程式碼的話,就可以盜取別人的 Token,又由於伺服器端完全信任 Token 的關係,幾乎完全無法防止別人使用該 Token 去當作你的身份,用此身份來進行伺服器操作,這種攻擊通常稱為 XSS(Cross-site scripting)。最常見的防止方式是將 Token 一樣使用 Cookie 去帶,並且對該 Cookie 使用 HttpOnly 和 Secure Flag 讓 Cookie 內容不能被 JavaScript 讀取。詳細關於 XSS 的內容可見 Wiki
  3. 如果你所處的網路環境正在被監聽的話,不管是 Session-Based Authentication 或是 Token-Based Authentication 都有可能因為被監聽而 Cookie 或是 Token 被其他人偷走,進而導致帳號被盜,這種攻擊一般稱作 MitM(Man-in-the-Middle Attack)。通常的解決方式就是讓客戶端與伺服器端連線之間透過加密資料進行溝通,最常見的加密方式是使用 SSL(Secure Sockets Layer)加密技術,而溝通的 protocol 就會從 http 改成後面加上 SSL 第一個字的 https。詳細關於 MitM 的部分可以參見 Wiki
  4. 以 RESTful API 的角度看,我們曾經提到它有個原則叫做 Stateless,就是只要我送給伺服器的參數相同,應該就會得到一樣的結果,伺服器並沒有記錄任何狀態下來,去影響客戶端送出的 Request 要回應的內容。以這個特點來看,Token-Based Authentication 就比較符合這個原則,而 Session-Based Authentication 就會比較違反這個原則。
  5. Session-Based Authentication 由於在每次驗證建立後,都必須使用空間來儲存辨識使用者的資料;而 Token-Based Authentication 則是將這些資料交給客戶端儲存。如果以伺服器可以服務的用戶數量的量級來比較的話,就是 Token-Based Authentication 的方式能夠服務的用戶數量會高很多。但相對地,就會有第三點所提到的問題,伺服器端無法主動終止該 Token 的驗證,必須等到 Token 所含的過期時間到了才能被終止,所以通常的做法就是讓 Token 的過期時間很短,以防止 Token 被偷,那這樣對使用者的影響就是常常會需要重新輸入帳號密碼去做登入,就比較不適合用於需要長時間驗證的服務上。

綜合以上的優缺點比較,可得出以下結論

1.單伺服器的情況下,Session-Base和Token-Base可以擇一使用。
2.多伺服器的情況下,建議使用Token-Base驗證。

以此專案的某些考量,個人最後決定使用Token-Base Authentication當作API的驗證機制。
首先先來處理後端部分,有產生Token和檢查Token兩部分須實作
先處理檢查Token的機制

打開appsettings.json,加入JWT的相關設定

//JWT設定
---------------------------------------------
"JwtSettings": {
    "Issuer": "FirstReact",
    "SignKey": "1234567890123456789012",
    "ExpireMinutes": 30
  }
---------------------------------------------

然後打開program.cs,設定目前權限的驗證方式

Program.cs(位置放在builder.Services.AddControllersWithViews()的後面)
-------------------------------------------------------------------------------
#region Token-Base Authentication
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();
#endregion
-------------------------------------------------------------------------------

下方再加上驗證和授權(須放在UseRouting後面)

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

將需要驗證的Controller或Action加上[Authorize],如下面範例

這樣檢查Token的部分就完成了,可以使用偵錯模式試試看,沒驗證成功會固定返回[401]Unauthorized


再來處理產生Token那段,在Services資料夾內新增JwtService.cs

JwtService.cs
-------------------------------------------------------------------------------

using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
using System.Text;
using System.IdentityModel.Tokens.Jwt;
namespace FirstReact.Services
{
    public class JwtService
    {
        private readonly IConfiguration Configuration;
        public JwtService(IConfiguration configuration)
        {
            this.Configuration = configuration;
        }
        public string GenerateToken(string MemberID, int expireMinutes = 30)
        {
            int configExpireMinutes= Configuration.GetValue<int>("JwtSettings:ExpireMinutes");
            if (configExpireMinutes==0) {
                configExpireMinutes = expireMinutes;
            }
            var issuer = Configuration.GetValue<string>("JwtSettings:Issuer");
            var signKey = Configuration.GetValue<string>("JwtSettings:SignKey");
            // 設定要加入到 JWT Token 中的聲明資訊(Claims)
            var claims = new List<Claim>();
            // 在 RFC 7519 規格中(Section#4),總共定義了 7 個預設的 Claims,我們應該只用的到兩種!
            //claims.Add(new Claim(JwtRegisteredClaimNames.Iss, issuer));
            claims.Add(new Claim(JwtRegisteredClaimNames.Sub, MemberID)); // User.Identity.Name
            //claims.Add(new Claim(JwtRegisteredClaimNames.Aud, "The Audience"));
            //claims.Add(new Claim(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.AddMinutes(30).ToUnixTimeSeconds().ToString()));
            //claims.Add(new Claim(JwtRegisteredClaimNames.Nbf, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())); // 必須為數字
            //claims.Add(new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())); // 必須為數字
            claims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())); // JWT ID
            // 網路上常看到的這個 NameId 設定是多餘的
            //claims.Add(new Claim(JwtRegisteredClaimNames.NameId, userName));
            // 這個 Claim 也以直接被 JwtRegisteredClaimNames.Sub 取代,所以也是多餘的
            //claims.Add(new Claim(ClaimTypes.Name, userName));
            // 你可以自行擴充 "roles" 加入登入者該有的角色
            claims.Add(new Claim("roles", "Admin"));
            claims.Add(new Claim("roles", "Users"));
            var userClaimsIdentity = new ClaimsIdentity(claims);
            // 建立一組對稱式加密的金鑰,主要用於 JWT 簽章之用
            var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signKey));
            // HmacSha256 有要求必須要大於 128 bits,所以 key 不能太短,至少要 16 字元以上
            // https://stackoverflow.com/questions/47279947/idx10603-the-algorithm-hs256-requires-the-securitykey-keysize-to-be-greater
            var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);
            
            // 建立 SecurityTokenDescriptor
            var tokenDescriptor = new SecurityTokenDescriptor
            {
                Issuer = issuer,
                //Audience = issuer, // 由於你的 API 受眾通常沒有區分特別對象,因此通常不太需要設定,也不太需要驗證
                //NotBefore = DateTime.Now, // 預設值就是 DateTime.Now
                //IssuedAt = DateTime.Now, // 預設值就是 DateTime.Now
                Subject = userClaimsIdentity,
                //Token有效時間,如有設定已設定優先
                Expires = DateTime.Now.AddMinutes(configExpireMinutes),
                SigningCredentials = signingCredentials
            };
            // 產出所需要的 JWT securityToken 物件,並取得序列化後的 Token 結果(字串格式)
            var tokenHandler = new JwtSecurityTokenHandler();
            var securityToken = tokenHandler.CreateToken(tokenDescriptor);
            var serializeToken = tokenHandler.WriteToken(securityToken);
            return serializeToken;
        }
    }
}

-------------------------------------------------------------------------------

在Program.cs裡加入下面的程式碼(須放在var app=builder.Build()前面)

//JWT簽發
builder.Services.AddSingleton<JwtService>();

在Models內新增SignInViewModel,等等方法會使用到

SignInViewModel.cs
-------------------------------------------------------------------------------
namespace FirstReact.Models
{
    public record SignInRequestViewModel(string Account,string Password);
    public record SignInResponseViewModel(string Token);
}
-------------------------------------------------------------------------------

在Controllers內新增LoginController.cs,裡面會內含一個SignIn的Action

LoginController.cs
-------------------------------------------------------------------------------
using FirstReact.Models;
using FirstReact.Services;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace FirstReact.Controllers
{
    [ApiController]
    [Route("api/[controller]/[Action]")]
    public class LoginController : ControllerBase
    {
        private readonly JwtService _jwt;
        private readonly ILogger<WeatherForecastController> _logger;
        public LoginController(ILogger<WeatherForecastController> logger, JwtService jwt)
        {
            _logger = logger;
            _jwt=jwt;
        }
        [HttpPost]
        public string SignIn(SignInRequestViewModel source)
        {
            string ret = "";
            if (
                source!=null 
                && !string.IsNullOrEmpty(source.Account)
                && !string.IsNullOrEmpty(source.Password)
                ) {
                SignInResponseViewModel res = new(_jwt.GenerateToken(source.Account));
                ret = JsonSerializer.Serialize(res);
            }
            return ret;
        }
    }
}

-------------------------------------------------------------------------------

這樣就實作好會發Token的登入API,使用Postman去呼叫看看,確實有回傳Token。

用Token去呼叫剛剛需要驗證的API,加入方式是在HTTP Request Header 加入 Authorization: Bearer {TOKEN}

加入前會回傳401

加入後就能成功回傳資料!


這樣API就有基本的驗證機制了,但還有許多需要調整部分,例如Token強制失效或是加入Role權限...等等。
這些待後續有需求後再來補充。

參考文章:

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










沒有留言:

張貼留言

【.Net Core】 EF Core + Web API 實作

 EF Core是Entity Framework在.Net Core使用的版本,功能幾乎相同,但具有輕巧、高擴充性以及高效能等優點,建議各位學習。 通常在.Net Core如果要用ORM的方式會有兩種選擇分別是EF Core以及Dapper。 從其他網路文章可以看到這兩種在最新...