零信任的核心是一句話:「永不信任,始終驗證」。聽起來像口號,落到 .NET Core Web API 上,它其實是一串很具體的工程動作——每個請求都要驗 JWT、每個端點都要授權、敏感資料落地前要加密、所有存取都要留稽核紀錄。

網路上談零信任的文章很多,但大多停在「驗證 token」這一步,把怎麼簽發 token、設定值放哪、middleware 怎麼掛這些「跑起來真正需要的東西」省略掉了。照著抄常常編譯失敗,或執行期吃一個 NullReferenceException

這篇要做的,是一個從 dotnet new 開始、能完整跑起來的零信任 Web API。每段程式碼都接得上前一段,最後給你完整的 Program.csappsettings.json

五個原則,對應五件事

零信任這套架構,我把它拆成五個原則,每一個都對應後面一段實作:

  1. 永遠驗證 — 每個請求都驗身分(JWT 簽發與驗證)
  2. 最小權限 — 只給必要的存取權(授權政策)
  3. 假設破壞 — 當作網路已經被打進來(限流、安全標頭)
  4. 全程加密 — 通訊與落地資料都加密(HTTPS、AES)
  5. 持續監控 — 記錄並分析所有活動(稽核日誌、Application Insights)

往下走的時候,你會發現這五件事彼此咬合:沒有簽發就沒有驗證,沒有授權政策最小權限就是空談。

建專案、裝套件

先把專案開起來:

1
2
dotnet new webapi -n ZeroTrustApi
cd ZeroTrustApi

接著一次把這篇會用到的套件全裝齊。這份清單我列得比一般教學長,因為少裝任何一個,後面對應的程式碼就會編譯失敗——健康檢查那段尤其容易漏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 認證與身分
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer

# 限流
dotnet add package AspNetCoreRateLimit

# 結構化日誌
dotnet add package Serilog.AspNetCore

# 健康檢查(這三個是 UIResponseWriter、AddUrlGroup、AddDbContextCheck 各自的來源)
dotnet add package AspNetCore.HealthChecks.UI.Client
dotnet add package AspNetCore.HealthChecks.Uris
dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore

# APM(最後一段才用到)
dotnet add package Microsoft.ApplicationInsights.AspNetCore

# 單元測試(測試專案才裝,主專案不需要)
dotnet add package Moq

AspNetCore.HealthChecks.UI.Client 這個很多人會漏。後面健康檢查端點用到的 UIResponseWriter.WriteHealthCheckUIResponse 就在這個套件裡,沒裝就編不過。

appsettings.json:先把設定值補齊

很多教學的程式碼裡寫 builder.Configuration["Jwt:Key"],卻從來沒給過設定檔長什麼樣。結果讀者照抄程式碼一跑,Configuration["Jwt:Key"] 回傳 null,建簽章金鑰時直接吃一個 NullReferenceException

所以設定檔先給。把下面這份放進 appsettings.json

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
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=ZeroTrustDb;Trusted_Connection=True;TrustServerCertificate=True;"
},
"Jwt": {
"Issuer": "https://api.yourdomain.com",
"Audience": "https://app.yourdomain.com",
"Key": "請改成至少 32 字元的隨機字串-不要用這個範例值-CHANGE-ME-32CHARS",
"ExpiryMinutes": 60
},
"Encryption": {
"KeyBase64": "請用 32 bytes 隨機值的 Base64-下面教你怎麼產"
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
}
},
"IpRateLimit": {
"EnableEndpointRateLimiting": true,
"StackBlockedRequests": false,
"HttpStatusCode": 429,
"RealIpHeader": "X-Real-IP"
}
}

Jwt:Key 是 HMAC-SHA256 的對稱金鑰,長度至少 32 bytes(256 bits),太短 .NET 在簽發時會直接拒絕。Encryption:KeyBase64 是 AES 金鑰,要剛好 16/24/32 bytes 才合法,下面那段加密服務會講怎麼產。

提醒一句:這份只是讓本地跑得起來的範本。Jwt:KeyEncryption:KeyBase64 是祕密,正式環境一律改放 Azure Key Vault 或環境變數,絕對不要連同這兩個值一起 commit 進版控

要產一把合法的 AES 金鑰,跑這行就好:

1
2
3
4
5
# Linux / macOS
openssl rand -base64 32

# Windows PowerShell
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Max 256 }))

身分模型與資料庫

零信任要驗身分,身分得先有地方存。先定義使用者模型,繼承 IdentityUser 拿到內建的帳號、密碼雜湊、鎖定機制:

1
2
3
4
5
6
public class ApplicationUser : IdentityUser
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public DateTime LastLoginTime { get; set; }
}

再來是 EF Core 的資料庫上下文,繼承 IdentityDbContext 就能把 Identity 那一整套資料表掛上去:

1
2
3
4
5
6
7
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
}

光定義還不夠,這兩個類別要在 Program.cs 裡註冊進 DI 才會生效——這一步漏掉,後面 UserManagerSignInManager 全都注入不進去。註冊的完整寫法放在後面的 Program.cs 整段。

DbContext 定義完、Program.cs 也把 AddDbContext 掛好之後,要跑一次 EF Core 的 migration 才會在資料庫建出實際的資料表,少這一步啟動時就會吃 Table 'xxx' doesn't exist

1
2
3
4
5
# 建立第一次 migration(只需要跑一次,之後有 schema 異動再跑)
dotnet ef migrations add Init

# 把 migration 套用到資料庫(每次部署新 migration 都要跑)
dotnet ef database update

如果 dotnet ef 指令找不到,先裝 EF Core CLI 工具:

1
dotnet tool install --global dotnet-ef

簽發 JWT:登入端點

這是最常被省略、卻最關鍵的一段。沒有簽發端點,使用者拿不到 token,整個驗證流程就是空轉。

下面這個 AuthController 做三件事:用 SignInManager 驗帳密、驗過了用 JwtSecurityTokenHandler 簽一張 token、把 token 回給前端。scope claim 帶 api_access,對應後面授權政策會檢查的那個 claim:

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
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IConfiguration _config;

public AuthController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IConfiguration config)
{
_userManager = userManager;
_signInManager = signInManager;
_config = config;
}

public record LoginRequest(string Email, string Password);

[HttpPost("login")]
[AllowAnonymous] // 登入端點本身不能要 token,否則沒人拿得到第一張
public async Task<IActionResult> Login(LoginRequest request)
{
var user = await _userManager.FindByEmailAsync(request.Email);
if (user is null)
{
// 找不到帳號也回 401,不洩漏「這個 email 有沒有註冊」
return Unauthorized();
}

var result = await _signInManager.CheckPasswordSignInAsync(
user, request.Password, lockoutOnFailure: true);
if (!result.Succeeded)
{
return Unauthorized();
}

user.LastLoginTime = DateTime.UtcNow;
await _userManager.UpdateAsync(user);

var token = GenerateToken(user);
return Ok(new { token });
}

private string GenerateToken(ApplicationUser user)
{
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Email, user.Email!),
new(ClaimTypes.Name, user.UserName!),
// 對應 RequireApiAccess 政策檢查的 scope claim
new("scope", "api_access"),
// 每張 token 一個唯一 ID,方便日後做撤銷清單
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};

var expiry = DateTime.UtcNow.AddMinutes(
_config.GetValue<int>("Jwt:ExpiryMinutes"));

var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
expires: expiry,
signingCredentials: credentials);

return new JwtSecurityTokenHandler().WriteToken(token);
}
}

簽發用的 Jwt:KeyJwt:IssuerJwt:Audience 必須跟下面驗證端設定的完全一樣,不然簽出來的 token 在驗證時會被擋掉。這是另一個常見的坑:簽發和驗證各讀一份不同的設定,token 永遠驗不過。

驗證 JWT

有了簽發,驗證端才有意義。在 Program.cs 設定 JWT Bearer,把該開的驗證全部打開:

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
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],

// 預設有 5 分鐘容差,過期判斷會晚 5 分鐘生效,這裡關掉
ClockSkew = TimeSpan.Zero,
RequireExpirationTime = true,

IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};

// token 過期時在回應標頭給前端一個明確訊號,方便它觸發 refresh
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
if (context.Exception is SecurityTokenExpiredException)
{
context.Response.Headers.Append("Token-Expired", "true");
}
return Task.CompletedTask;
}
};
});

ClockSkew 預設是 5 分鐘,意思是 token 過期後還能再撐 5 分鐘才被擋。對一般網站無所謂,對零信任這種「過期就該立刻失效」的場景,把它設成 TimeSpan.Zero

授權:最小權限政策

驗過身分只代表「你是誰」,授權才決定「你能做什麼」。定義兩條政策——一條限管理員角色,一條檢查前面 token 帶的 api_access scope:

1
2
3
4
5
6
7
8
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireAdminRole", policy =>
policy.RequireRole("Admin"));

options.AddPolicy("RequireApiAccess", policy =>
policy.RequireClaim("scope", "api_access"));
});

控制器上掛 [Authorize(Policy = "RequireApiAccess")],請求就必須帶著 scope=api_access 的 token 才進得來。

有一個坑在這裡值得特別說。如果你的 Program.cs 是先呼叫 AddIdentity<ApplicationUser, IdentityRole>(...) 再呼叫 AddAuthentication(JwtBearerDefaults.AuthenticationScheme),帶 JWT 的請求打到 [Authorize] 端點時會直接吃 401,不管 token 多正確。

原因是 AddIdentity 內部會把 DefaultAuthenticateSchemeDefaultChallengeScheme 設成 Cookie scheme,後面的 AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 蓋不掉這個設定。修法是改用 AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>()——它提供同樣的 UserManagerSignInManager,但不干涉預設 scheme,讓 JWT 設定完整生效。這份教學的 Program.cs 就是這樣寫的。

限流:擋住暴力嘗試

「假設破壞」意味著要防有人拿到一個端點就狂打。AspNetCoreRateLimitMemoryCacheIpPolicyStoreMemoryCacheRateLimitCounterStore 都依賴 IMemoryCache,所以得先 AddMemoryCache(),順序錯了執行期直接拋例外。

這裡要特別說明一個常見陷阱:AddInMemoryRateLimiting() 內部已經幫你註冊了 IIpPolicyStoreIRateLimitCounterStoreIRateLimitConfiguration,如果你同時又手動 AddSingleton 這三個,就會在 DI 容器裡重複登記,行為不可預測。兩種寫法只能選一:要麼全手動、要麼全交給 AddInMemoryRateLimiting()IProcessingStrategyAsyncKeyLockProcessingStrategy)是 AddInMemoryRateLimiting() 沒有幫你加的,無論哪種寫法都要自己補上。這份教學選擇全手動,保持意圖明確:

1
2
3
4
5
6
7
8
9
10
// 一定要先註冊 IMemoryCache,下面那幾個 store 才有東西可依賴
builder.Services.AddMemoryCache();

builder.Services.Configure<IpRateLimitOptions>(
builder.Configuration.GetSection("IpRateLimit"));
builder.Services.AddSingleton<IIpPolicyStore, MemoryCacheIpPolicyStore>();
builder.Services.AddSingleton<IRateLimitCounterStore, MemoryCacheRateLimitCounterStore>();
builder.Services.AddSingleton<IProcessingStrategy, AsyncKeyLockProcessingStrategy>();
builder.Services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>();
// 注意:不再額外呼叫 AddInMemoryRateLimiting(),以上四行已包含同等功能

限流規則本身可以寫在程式碼裡,也可以放 appsettings.json。寫在程式碼的版本長這樣:每分鐘 30 次、每小時 500 次:

1
2
3
4
5
6
7
8
builder.Services.Configure<IpRateLimitOptions>(options =>
{
options.GeneralRules = new List<RateLimitRule>
{
new() { Endpoint = "*", Period = "1m", Limit = 30 },
new() { Endpoint = "*", Period = "1h", Limit = 500 }
};
});

安全標頭中介軟體

這個 middleware 的工作是替每個回應加上安全相關的 HTTP 標頭。這裡有個細節要注意:X-XSS-Protection 在 Chrome 78+、Firefox、Edge 都已經移除,現代瀏覽器靠 Content-Security-Policy 防 XSS,所以不再加它。

另一個容易踩的點是寫法。Response.Headers.Add(...) 在 .NET 7 之後對「標頭已存在」的處理會丟 ArgumentException,某些情境下標頭會靜默失效。改用索引賦值或 .Append,行為才穩定:

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
public class SecurityHeadersMiddleware
{
private readonly RequestDelegate _next;

public SecurityHeadersMiddleware(RequestDelegate next)
{
_next = next;
}

public async Task InvokeAsync(HttpContext context)
{
// 用索引賦值取代 .Add():重複設定不會拋例外,行為一致
// 防點擊劫持
context.Response.Headers["X-Frame-Options"] = "DENY";
// 防 MIME 嗅探
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
// 控制 Referer 標頭
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
// 限制資源載入來源(依需求調整),取代已棄用的 X-XSS-Protection
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; script-src 'self'";

await _next(context);
}
}

稽核日誌中介軟體

「持續監控」最基本的一層,是把每個請求的關鍵欄位記下來:誰、什麼時間、從哪個 IP、打了哪個端點。

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
public class AuditLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<AuditLoggingMiddleware> _logger;

public AuditLoggingMiddleware(
RequestDelegate next,
ILogger<AuditLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}

public async Task InvokeAsync(HttpContext context)
{
var auditLog = new
{
Timestamp = DateTime.UtcNow,
RequestPath = context.Request.Path.Value,
RequestMethod = context.Request.Method,
UserAgent = context.Request.Headers.UserAgent.ToString(),
UserIp = context.Connection.RemoteIpAddress?.ToString(),
UserId = context.User.Identity?.Name
};

_logger.LogInformation("API Request: {@AuditLog}", auditLog);

await _next(context);
}
}

{@AuditLog} 前面那個 @ 是 Serilog 的解構運算子,會把整個物件當結構化資料記下來,之後在日誌平台才能用欄位查詢,而不是塞一坨字串。

敏感資料加密服務

「全程加密」不只指傳輸層的 HTTPS,落地的敏感欄位也要加密。這段是整篇最容易寫錯、也最危險的地方。

最常見的錯誤是用固定 IV。AES-CBC 配固定 IV 是嚴重缺陷(CWE-329):相同明文每次都加出相同密文,攻擊者可以靠這點推測內容。正確做法是每次加密產生新的隨機 IV,把 IV 一起寫進密文前段,解密時先取出來再解。

另外,金鑰長度一定要在建構時就驗。AES 只接受 16/24/32 bytes,傳錯長度的話,.NET 會在加密那一刻才丟一個含糊的 CryptographicException,被抄走的程式碼根本不知道是金鑰問題。提早驗、給明確錯誤訊息,能省掉別人半小時的 debug:

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
public interface IEncryptionService
{
string Encrypt(string plainText);
string Decrypt(string cipherText);
}

public class EncryptionService : IEncryptionService
{
private readonly byte[] _key;

public EncryptionService(IConfiguration configuration)
{
var keyBase64 = configuration["Encryption:KeyBase64"];
if (string.IsNullOrEmpty(keyBase64))
{
throw new InvalidOperationException(
"缺少設定 Encryption:KeyBase64,請在 appsettings 或環境變數補上。");
}

_key = Convert.FromBase64String(keyBase64);

// 提早驗長度,給出比 CryptographicException 清楚的訊息
if (_key.Length is not (16 or 24 or 32))
{
throw new InvalidOperationException(
$"AES 金鑰長度必須是 16/24/32 bytes,目前是 {_key.Length} bytes。" +
"用 openssl rand -base64 32 產一把合法金鑰。");
}
}

public string Encrypt(string plainText)
{
using var aes = Aes.Create();
aes.Key = _key;
aes.GenerateIV(); // 每次加密都換一把隨機 IV

using var ms = new MemoryStream();
// 先把 IV(16 bytes)寫進密文最前面,解密時從這裡取回
ms.Write(aes.IV, 0, aes.IV.Length);

using (var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write))
using (var sw = new StreamWriter(cs))
{
sw.Write(plainText);
}

return Convert.ToBase64String(ms.ToArray());
}

public string Decrypt(string cipherText)
{
var fullCipher = Convert.FromBase64String(cipherText);

using var aes = Aes.Create();
aes.Key = _key;

// 從密文前 16 bytes 取回加密時用的 IV
var iv = new byte[16];
Array.Copy(fullCipher, 0, iv, 0, iv.Length);
aes.IV = iv;

using var ms = new MemoryStream(fullCipher, iv.Length, fullCipher.Length - iv.Length);
using var cs = new CryptoStream(ms, aes.CreateDecryptor(), CryptoStreamMode.Read);
using var sr = new StreamReader(cs);
return sr.ReadToEnd();
}
}

如果你的 .NET 版本支援,正式環境我會建議直接改用 AES-GCM。它在加密之外還附帶資料完整性驗證,能擋住密文被竄改的攻擊,比 CBC 多一層保障。

結構化日誌(Serilog)

builder.Services.AddSerilog() 是 Serilog.AspNetCore 8.0 之後的推薦寫法,它把 Serilog 加進 MEL 的 provider 清單,不會整個取代掉 MEL 基礎設施。builder.Host.UseSerilog() 仍然可用(接在 IHostBuilder 上),只是新版更建議改用 AddSerilog() 統一透過 DI 管線注入:

1
2
3
4
builder.Services.AddSerilog((services, lc) => lc
.ReadFrom.Configuration(builder.Configuration)
.WriteTo.Console()
.WriteTo.File("logs/api-.txt", rollingInterval: RollingInterval.Day));

細部的日誌等級控制就交給前面 appsettings.json 那段 Serilog 區塊,把 Microsoft 和 System 的噪音壓到 Warning,自己的應用維持 Information。

CORS

跨域設定遵循最小開放原則,只放行明確列出的來源、方法和標頭,不要圖方便開 AllowAnyOrigin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
builder.Services.AddCors(options =>
{
options.AddPolicy("SecurePolicy", policy =>
{
policy
.WithOrigins(
"https://yourdomain.com",
"https://api.yourdomain.com")
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Authorization", "Content-Type")
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromMinutes(10));
});
});

注意 AllowCredentials() 不能跟 AllowAnyOrigin() 並用,瀏覽器會直接拒絕——這也是為什麼上面要一個個列出 WithOrigins

健康檢查

健康檢查是維運層面「持續監控」的入口。這裡用到一個自訂的磁碟空間檢查——DiskSpaceHealthCheck 不是內建類別,框架裡沒有這個名字,要自己實作 IHealthCheck

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
// 自訂磁碟空間健康檢查
public class DiskSpaceHealthCheck : IHealthCheck
{
private readonly long _minimumFreeMb;

public DiskSpaceHealthCheck(long minimumFreeMb)
{
_minimumFreeMb = minimumFreeMb;
}

public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
// DriveInfo("/") 在 Windows 會丟 ArgumentException,改用 GetDrives() 取第一顆磁碟
// Linux/macOS 根目錄是第一顆,Windows 則通常是 C:\
var drive = DriveInfo.GetDrives().FirstOrDefault(d => d.IsReady);
if (drive is null)
{
return Task.FromResult(HealthCheckResult.Unhealthy("找不到可用磁碟"));
}

var availableMb = drive.AvailableFreeSpace / (1024 * 1024);
return Task.FromResult(availableMb >= _minimumFreeMb
? HealthCheckResult.Healthy($"可用磁碟空間:{availableMb} MB({drive.Name})")
: HealthCheckResult.Unhealthy(
$"磁碟空間不足:{availableMb} MB(最低需求 {_minimumFreeMb} MB,磁碟:{drive.Name})"));
}
}

註冊健康檢查時,AddDbContextCheck 來自 Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCoreAddUrlGroup 來自 AspNetCore.HealthChecks.Uris,這兩個就是前面安裝清單裡那兩個套件。端點的 ResponseWriter 用的 UIResponseWriter 則來自 AspNetCore.HealthChecks.UI.Client

1
2
3
4
5
6
7
8
9
10
builder.Services.AddHealthChecks()
.AddDbContextCheck<ApplicationDbContext>()
.AddUrlGroup(new Uri("https://dependent-service.com/health"), name: "dependency")
.AddCheck("DiskSpace", new DiskSpaceHealthCheck(1024));

// 端點設定(在 app 建好之後)
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

Application Insights

最後一層監控接上 Application Insights。連線字串放在 appsettings 的 ApplicationInsights:ConnectionString。這裡有個容易漏的點:自訂的 ITelemetryInitializer 定義完之後,要記得在 DI 裡註冊,不然它根本不會被呼叫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
builder.Services.AddApplicationInsightsTelemetry(options =>
{
options.ConnectionString =
builder.Configuration["ApplicationInsights:ConnectionString"];
options.EnableAdaptiveSampling = false; // 開發環境建議關,才看得到完整遙測
});

// 定義 telemetry initializer
public class CustomTelemetryInitializer : ITelemetryInitializer
{
public void Initialize(ITelemetry telemetry)
{
telemetry.Context.Cloud.RoleName = "ZeroTrustApi";
telemetry.Context.Cloud.RoleInstance = Environment.MachineName;
}
}

// 關鍵:一定要註冊進 DI,否則上面的 Initialize 永遠不會跑
builder.Services.AddSingleton<ITelemetryInitializer, CustomTelemetryInitializer>();

把整個 Program.cs 串起來

前面拆開講的設定,這裡組成一份完整、可跑的 Program.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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
using System.Text;
using AspNetCoreRateLimit;
using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

// 1. 資料庫
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

// 2. Identity(提供 UserManager / SignInManager)
// ⚠️ 注意:這裡刻意用 AddIdentityCore 而非 AddIdentity。
// AddIdentity 內部會呼叫 AddAuthentication 並把 DefaultAuthenticateScheme /
// DefaultChallengeScheme 設為 CookieAuthenticationDefaults.AuthenticationScheme,
// 導致後面再呼叫 AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 也
// 蓋不回來,最終帶 JWT 的請求到達 [Authorize] 端點時會被當成未登入而吃 401。
// AddIdentityCore 不設定任何 scheme 預設值,讓 JWT 設定完整接管。
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequiredLength = 8;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequireUppercase = true;
options.Password.RequireLowercase = true;
options.Lockout.MaxFailedAccessAttempts = 5;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();

// 提高密碼雜湊強度(PasswordHasherOptions 是獨立選項類別,不掛在 IdentityOptions 底下)
builder.Services.Configure<PasswordHasherOptions>(options =>
options.IterationCount = 100_000);

// 3. JWT 驗證
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
ClockSkew = TimeSpan.Zero,
RequireExpirationTime = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};
});

// 4. 授權政策
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireAdminRole", p => p.RequireRole("Admin"));
options.AddPolicy("RequireApiAccess", p => p.RequireClaim("scope", "api_access"));
});

// 5. 限流(AddMemoryCache 一定要在前面)
builder.Services.AddMemoryCache();
builder.Services.Configure<IpRateLimitOptions>(builder.Configuration.GetSection("IpRateLimit"));
builder.Services.AddSingleton<IIpPolicyStore, MemoryCacheIpPolicyStore>();
builder.Services.AddSingleton<IRateLimitCounterStore, MemoryCacheRateLimitCounterStore>();
builder.Services.AddSingleton<IProcessingStrategy, AsyncKeyLockProcessingStrategy>();
builder.Services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>();
// 四個 singleton 已涵蓋完整功能,不額外呼叫 AddInMemoryRateLimiting() 以避免重複登記

// 6. 加密服務、CORS、健康檢查、日誌、APM
builder.Services.AddSingleton<IEncryptionService, EncryptionService>();
builder.Services.AddCors(options =>
{
options.AddPolicy("SecurePolicy", policy => policy
.WithOrigins("https://yourdomain.com", "https://api.yourdomain.com")
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Authorization", "Content-Type")
.AllowCredentials());
});
builder.Services.AddHealthChecks()
.AddDbContextCheck<ApplicationDbContext>()
.AddCheck("DiskSpace", new DiskSpaceHealthCheck(1024));
builder.Services.AddSerilog((services, lc) => lc
.ReadFrom.Configuration(builder.Configuration)
.WriteTo.Console()
.WriteTo.File("logs/api-.txt", rollingInterval: RollingInterval.Day));
builder.Services.AddApplicationInsightsTelemetry(options =>
{
options.ConnectionString =
builder.Configuration["ApplicationInsights:ConnectionString"];
options.EnableAdaptiveSampling = false;
});
builder.Services.AddSingleton<ITelemetryInitializer, CustomTelemetryInitializer>();

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// ---- 中介軟體 pipeline:順序就是安全性本身 ----
app.UseHttpsRedirection(); // 先強制 HTTPS
app.UseHsts();

app.UseMiddleware<SecurityHeadersMiddleware>(); // 安全標頭越早加越好
app.UseIpRateLimiting(); // 限流要在認證之前,擋掉暴力嘗試

app.UseCors("SecurePolicy");

app.UseAuthentication(); // 先驗身分(你是誰)
app.UseAuthorization(); // 再驗權限(你能做什麼)

app.UseMiddleware<AuditLoggingMiddleware>(); // 認證之後才記得到 UserId

app.MapControllers();
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

app.Run();

順序這件事我特別標出來,因為它不是隨便排的:限流放在認證前面,攻擊者連 token 都還沒驗就被擋下,省掉無謂的運算;稽核日誌放在認證後面,才記得到 User.Identity.Name,放前面只會記到一片空白。UseAuthentication 一定在 UseAuthorization 之前,這是 ASP.NET Core 的硬規定,倒過來授權永遠失敗。

受保護的端點長什麼樣

設定都到位之後,一個受保護的控制器就很單純了——掛上 [Authorize],剩下的驗證 pipeline 已經幫你擋在門外。這裡同時示範了前面「全程加密」原則的落地:IEncryptionService 注入進控制器,敏感欄位(這裡用備註欄位為例)在回傳前加密儲存、讀取時解密,傳輸和落地都不走明文:

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
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = "RequireApiAccess")]
public class SecureController : ControllerBase
{
private readonly ILogger<SecureController> _logger;
private readonly IEncryptionService _encryption;

public SecureController(
ILogger<SecureController> logger,
IEncryptionService encryption)
{
_logger = logger;
_encryption = encryption;
}

[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public IActionResult Get()
{
_logger.LogInformation(
"Access by {User} at {Time}",
User.Identity?.Name, DateTime.UtcNow);

return Ok(new { message = "Secure data" });
}

// 示範:把敏感欄位加密後儲存
[HttpPost("notes")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult SaveNote([FromBody] NoteRequest request)
{
// 敏感欄位在落地前加密,資料庫只存密文
var encryptedNote = _encryption.Encrypt(request.Content);

_logger.LogInformation(
"Note saved (encrypted) by {User}", User.Identity?.Name);

// 實際專案這裡會呼叫 Repository 把 encryptedNote 寫進 DB
return Ok(new { stored = encryptedNote });
}

// 示範:從儲存的密文解密後回傳
[HttpGet("notes/{cipherText}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult ReadNote(string cipherText)
{
var plainText = _encryption.Decrypt(cipherText);

return Ok(new { content = plainText });
}
}

public record NoteRequest(string Content);

寫個測試確認它真的擋人

光看程式碼不算數,補一個單元測試驗證 SecureController 在帶身分時回 200:

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
public class SecureControllerTests
{
private readonly SecureController _controller;

public SecureControllerTests()
{
var loggerMock = new Mock<ILogger<SecureController>>();
var encryptionMock = new Mock<IEncryptionService>();
_controller = new SecureController(loggerMock.Object, encryptionMock.Object);

// 模擬一個已驗證的使用者
var claims = new[]
{
new Claim(ClaimTypes.Name, "testuser"),
new Claim("scope", "api_access")
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));

_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = principal }
};
}

[Fact]
public void Get_AuthenticatedUser_ReturnsOk()
{
var result = _controller.Get();
Assert.IsType<OkObjectResult>(result);
}
}

這個測試直接 new 控制器、塞一個假的 ClaimsPrincipal 進去,繞過 HTTP pipeline。要驗 [Authorize] 本身有沒有擋住匿名請求,得寫整合測試用 WebApplicationFactory 打真實端點,那是另一個主題。

容器化

Dockerfile 用多階段建置,最後只把發佈產物搬進輕量的 runtime 映像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["ZeroTrustApi.csproj", "./"]
RUN dotnet restore "ZeroTrustApi.csproj"
COPY . .
RUN dotnet publish "ZeroTrustApi.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "ZeroTrustApi.dll"]

CI/CD:別漏掉 Docker 登入

GitHub Actions 這段有個必踩的坑:docker/build-push-action 在 push 之前,一定要先跑 docker/login-action 完成認證,不然推送那步必定因為沒驗證而失敗。完整流程如下:

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
name: .NET Core CI/CD

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'

- name: Restore dependencies
run: dotnet restore

- name: Build
run: dotnet build --configuration Release --no-restore

- name: Test
run: dotnet test --no-restore --verbosity normal

# 必須先登入 Registry,否則下一步 push 一定失敗
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ vars.DOCKER_USERNAME }}/zerotrust-api:latest

幾條維運上的習慣

跑起來之後,這幾件事值得固定做:

dotnet list package --vulnerable 定期掃套件漏洞,搭配 Dependabot 自動提醒。零信任做得再好,一個有已知 CVE 的相依套件就能把整道牆打穿。

祕密一律放 Azure Key Vault 或同等的祕密管理服務,不在程式碼或設定檔裡硬編任何金鑰、密碼。前面那份 appsettings.json 的範例值,正式環境一個都不能留。

API 版本號寫進路由或 header,並保留舊版一段時間,讓既有用戶有遷移的緩衝。

收尾

這篇從 dotnet new 開始,把 JWT 簽發與驗證、最小權限授權、限流、AES 隨機 IV 加密、稽核日誌、健康檢查、容器化到 CI/CD 全部串成一條能跑的線。前面拆開講的每段程式碼,最後都在那份完整的 Program.cs 裡接上了。

如果只挑幾個最容易翻車的點記住,我會選這四個:Jwt:Key 沒設好會在啟動時吃 NullReferenceExceptionAspNetCoreRateLimit 必須先 AddMemoryCache();AES 每次加密都要換隨機 IV;中介軟體的順序(限流在認證前、稽核在認證後)本身就是安全性的一部分。這幾個不是「之後再優化」的選項,跳過任何一個,零信任在實際部署時就會破一個洞。

參考資料

* [ASP.NET Core 安全性文件](https://docs.microsoft.com/zh-tw/aspnet/core/security/) * [OWASP Top 10 API Security Risks](https://owasp.org/www-project-api-security/) * [Microsoft Identity Platform](https://docs.microsoft.com/zh-tw/azure/active-directory/develop/) * [ASP.NET Core 應用程式監控](https://docs.microsoft.com/zh-tw/azure/azure-monitor/app/asp-net-core) * [Docker 容器化最佳實踐](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/) * [.NET Core 中的密碼雜湊](https://docs.microsoft.com/zh-tw/aspnet/core/security/data-protection/consumer-apis/password-hashing) * [CBC Mode Decryption Vulnerability - Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/standard/security/vulnerabilities-cbc-mode) * [ASP0019:建議使用 IHeaderDictionary.Append - Microsoft Learn](https://learn.microsoft.com/en-us/aspnet/core/diagnostics/asp0019) * [X-XSS-Protection - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection) * [Serilog.AspNetCore - GitHub](https://github.com/serilog/serilog-aspnetcore) * [AspNetCore.HealthChecks.UI.Client - NuGet](https://www.nuget.org/packages/AspNetCore.HealthChecks.UI.Client) * [AspNetCoreRateLimit Wiki](https://github.com/stefanprodan/AspNetCoreRateLimit/wiki)