化繁為簡:03 後端篇(二)驗證

img

前言

在上一篇 化繁為簡:02 後端篇(一) 中,我們最後製作一個可以添加內容安全策略 (Content Security Policy) 的 Middleware,其實還有很多事情我們可以集中到 Middleware 處理,例如操作紀錄,我們在 Controllers 內僅能了解功能面的存取狀況,但是拉到 Middleware 來紀錄時,就可以了解至整個作業面的資料存取是否合理,如果發現異常就可以加入還名單並終止使用者權限(中斷他的請求),而這些集中化的紀錄還可以拿來分析優化系統,說了這些,我們今天卻不是要實作它,重要的是要說明我們為了加速開發而在弱化整個系統架構時,其實後續事可以逐步強化回來,讓系統回覆該有的嚴謹度,在某種程度上與敏捷式開發有著同樣的目標。

雖著智慧手機的興起,大量的程式設計師透入 App 開發,以往系統間的資料交換首選幾乎都是以嚴謹聞名的 XML 格式,如今雖著 App 的迅速發展,有著輕量化的 JSON 資料格式已經幾乎取代了 XML 的地位,而隨著 JSON 的流行 JWT(JSON Web Tokens) 也變成很普及的身分驗證方式,今天我們就來弱化 JWT。

JSON Web Tokens

JWT 主要的流程就是當我們登入(驗證)成功時,系統會產生一組可以代表我們身分的 Token,後續發出請求(Request)時只要夾帶這組 Token,後端系統就可以藉此來驗證他的身分,這樣可以避免發出請求時需要夾帶敏感的身分資料。
img

下面程式碼是一個簡單產生 Token 的作法,我們可在 Token 內夾帶一些其他資訊,例如:有效期限、有效簽證,但是使用者的敏感資料就避免存放。

AuthController.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
36
37
38
39
40
41
42
43
44
45
46
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;

namespace WebAPI.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class AuthController : ControllerBase
{
private readonly IConfiguration Config;
public AuthController(IConfiguration config)
{
Config = config;
}

//'''

private string BuildToken(Models.SysUser user, string tokenUid, string companyName)
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sid, tokenUid),
new Claim(JwtRegisteredClaimNames.UniqueName, user.Name),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(JwtRegisteredClaimNames.FamilyName, companyName)
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Config["Tokens:IssuerSigningKey"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256Signature);

var token = new JwtSecurityToken(
issuer: Config["Tokens:ValidIssuer"],
audience: Config["Tokens:ValidAudience"],
claims: claims,
expires: DateTime.Now.AddDays(1),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}

}
}

這常情況下我們會在 Token 存放可以代表使用者的資訊,例如:User Id,不過筆者會建立一個資料表(log_login),每次登入驗證成功之後系統會產生一組 臨時ID 來替代使用者真實的 ID,並存放到 Token 的 Sid 內,如下圖,同一個使用者(user_uid) 每次登入存儲在 Token 內的 Sid 都會不同,多一個資料表就可以讓安全性再提高一點。
img

筆者此系統是讓一個帳號(使用者 User)可以使多家公司(Company),所以中間會多一個員工(Employee)來關聯。

AuthorizationMiddleware

接下來我們要建立一個 Authorization 的中介軟體,其主要的功能是解析 JWT 的 Token,並將 Token 轉換回使用者,這樣後續的 Controller 就可以直接抓取使用者資訊。
img

AuthorizationMiddleware.cs 的主要程式碼如下,這邊我們並沒有去驗證 Token 的授權是否有效,而是僅有解析 Token 資料,跟 資料表 log_login 比對,如果有資料就順便把該員工資料填入到 context.Items["emp"] 內,後面的程式就從此參數取得目前的請求是由哪一位員工發出。

AuthorizationMiddleware.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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;

namespace WebAPI
{
public class AuthorizationMiddleware
{
private readonly RequestDelegate _next;
public AuthorizationMiddleware(RequestDelegate next)
{
_next = next;
}

public async Task Invoke(HttpContext context, Models.CloudContext db)
{
if (!CheckAuthorization(context, db, out string message))
{
if (string.IsNullOrEmpty(message))
message = "Unauthorized";

context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync(message);
return;
}
await _next(context);
}

static bool CheckAuthorization(HttpContext context, Models.CloudContext db, out string message)
{
message = string.Empty;
string path = context.Request.Path.Value.ToLower();

switch (path)
{
case "/api/auth/login":
return true;
default:
break;
}
if (context.Request.Headers.Keys.Contains("Authorization"))
{
if (context.Request.Headers.Keys.Contains("Authorization"))
{
string accesToken = context.Request.Headers["Authorization"].ToString();
accesToken = accesToken.Replace("Bearer ", string.Empty);
var handler = new JwtSecurityTokenHandler().ReadJwtToken(accesToken);
if (handler != null)
{
var sidClaim = handler.Claims.FirstOrDefault(x => x.Type == JwtRegisteredClaimNames.Sid);
if (sidClaim != null)
{
if (Guid.TryParse(sidClaim.Value, out Guid tokenUid) && SetUserInfo(context, db, tokenUid))
{
return true;
}
}
}
}
}
message = "驗證錯誤,請重新登入";
return false;
}

static bool SetUserInfo(HttpContext context, Models.CloudContext db, Guid tokenUid)
{
var queryLog = db.LogLogins.FirstOrDefault(x => x.Uid == tokenUid);
if (queryLog == null)
return false;

var userInfo = new Models.SysEmployee
{
UserUid = queryLog.UserUid,
CompanyUid = queryLog.CompanyUid,
Email = string.Empty,
Uid = queryLog.EmployeeUid
};
context.Items["emp"] = userInfo;
return true;
}
}

partial class Ext
{
internal static IApplicationBuilder UseAuthorizationMiddleware(this IApplicationBuilder builder)
{
return builder.UseMiddleware<AuthorizationMiddleware>();
}
}
}

最後一樣在 Startup.csConfigure 內添加 app.UseAuthorizationMiddleware();,因為沒有使用到內建的驗證機制,所以可以先移除 app.UseAuthorization();
img

後記

AuthorizationMiddleware 內我們沒有驗證 Token 的授權證書,這看起來已經是捨棄 JTW 的稽核機制,筆者有另一種偷吃步的做法,就是將燈登入驗證的 Token 的資料也存放到資料表 log_login 內直接做字串比對。
對筆者來說 JWT 拿來做身分驗證是很實用的,但是不應該過度信任 JWT 的安全性,我們應該在它之外也加入其他安全稽核功能,還記得一開始我們說可以增加操作紀錄的 Middleware 嗎?當我們發現異常操作時可以在 log_login 內將該臨時 ID給註銷2掉(disable = true),這樣既使該 Token 仍然有時效,但是也無法再對後端做任何存取。

如果網站沒有其他軟硬體的 DDoS 防護機制,我們也可以在 Middleware 內加入基本的阻擋機制,針對不當存取的 IP 給予適當的封鎖。