零信任的核心是一句話:「永不信任,始終驗證」。聽起來像口號,落到 .NET Core Web API 上,它其實是一串很具體的工程動作——每個請求都要驗 JWT、每個端點都要授權、敏感資料落地前要加密、所有存取都要留稽核紀錄。
網路上談零信任的文章很多,但大多停在「驗證 token」這一步,把怎麼簽發 token、設定值放哪、middleware 怎麼掛這些「跑起來真正需要的東西」省略掉了。照著抄常常編譯失敗,或執行期吃一個 NullReferenceException。
這篇要做的,是一個從 dotnet new 開始、能完整跑起來的零信任 Web API。每段程式碼都接得上前一段,最後給你完整的 Program.cs 和 appsettings.json。
五個原則,對應五件事 零信任這套架構,我把它拆成五個原則,每一個都對應後面一段實作:
永遠驗證 — 每個請求都驗身分(JWT 簽發與驗證)
最小權限 — 只給必要的存取權(授權政策)
假設破壞 — 當作網路已經被打進來(限流、安全標頭)
全程加密 — 通訊與落地資料都加密(HTTPS、AES)
持續監控 — 記錄並分析所有活動(稽核日誌、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 dotnet add package AspNetCore.HealthChecks.UI.Client dotnet add package AspNetCore.HealthChecks.Uris dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore 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:Key 和 Encryption:KeyBase64 是祕密,正式環境一律改放 Azure Key Vault 或環境變數,絕對不要連同這兩個值一起 commit 進版控 。
要產一把合法的 AES 金鑰,跑這行就好:
1 2 3 4 5 openssl rand -base64 32 [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 才會生效——這一步漏掉,後面 UserManager、SignInManager 全都注入不進去。註冊的完整寫法放在後面的 Program.cs 整段。
DbContext 定義完、Program.cs 也把 AddDbContext 掛好之後,要跑一次 EF Core 的 migration 才會在資料庫建出實際的資料表,少這一步啟動時就會吃 Table 'xxx' doesn't exist:
1 2 3 4 5 dotnet ef migrations add Init 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 ] public async Task<IActionResult> Login (LoginRequest request ) { var user = await _userManager.FindByEmailAsync(request.Email); if (user is null ) { 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!), new ("scope" , "api_access" ), 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:Key、Jwt:Issuer、Jwt: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" ], ClockSkew = TimeSpan.Zero, RequireExpirationTime = true , IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key" ]!)) }; 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 內部會把 DefaultAuthenticateScheme 和 DefaultChallengeScheme 設成 Cookie scheme,後面的 AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 蓋不掉這個設定。修法是改用 AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>()——它提供同樣的 UserManager、SignInManager,但不干涉預設 scheme,讓 JWT 設定完整生效。這份教學的 Program.cs 就是這樣寫的。
限流:擋住暴力嘗試 「假設破壞」意味著要防有人拿到一個端點就狂打。AspNetCoreRateLimit 的 MemoryCacheIpPolicyStore 和 MemoryCacheRateLimitCounterStore 都依賴 IMemoryCache,所以得先 AddMemoryCache(),順序錯了執行期直接拋例外。
這裡要特別說明一個常見陷阱:AddInMemoryRateLimiting() 內部已經幫你註冊了 IIpPolicyStore、IRateLimitCounterStore、IRateLimitConfiguration,如果你同時又手動 AddSingleton 這三個,就會在 DI 容器裡重複登記,行為不可預測。兩種寫法只能選一:要麼全手動、要麼全交給 AddInMemoryRateLimiting()。IProcessingStrategy(AsyncKeyLockProcessingStrategy)是 AddInMemoryRateLimiting() 沒有幫你加的,無論哪種寫法都要自己補上。這份教學選擇全手動,保持意圖明確:
1 2 3 4 5 6 7 8 9 10 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>();
限流規則本身可以寫在程式碼裡,也可以放 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 ) { context.Response.Headers["X-Frame-Options" ] = "DENY" ; context.Response.Headers["X-Content-Type-Options" ] = "nosniff" ; context.Response.Headers["Referrer-Policy" ] = "strict-origin-when-cross-origin" ; 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); 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(); using var ms = new MemoryStream(); 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; 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 ) { 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.EntityFrameworkCore、AddUrlGroup 來自 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.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 ; }); public class CustomTelemetryInitializer : ITelemetryInitializer { public void Initialize (ITelemetry telemetry ) { telemetry.Context.Cloud.RoleName = "ZeroTrustApi" ; telemetry.Context.Cloud.RoleInstance = Environment.MachineName; } } 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 );builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection" ))); 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(); builder.Services.Configure<PasswordHasherOptions>(options => options.IterationCount = 100 _000); 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" ]!)) }; }); builder.Services.AddAuthorization(options => { options.AddPolicy("RequireAdminRole" , p => p.RequireRole("Admin" )); options.AddPolicy("RequireApiAccess" , p => p.RequireClaim("scope" , "api_access" )); }); 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>(); 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();app.UseHttpsRedirection(); app.UseHsts(); app.UseMiddleware<SecurityHeadersMiddleware>(); app.UseIpRateLimiting(); app.UseCors("SecurePolicy" ); app.UseAuthentication(); app.UseAuthorization(); app.UseMiddleware<AuditLoggingMiddleware>(); 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); 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 baseWORKDIR /app EXPOSE 8080 FROM mcr.microsoft.com/dotnet/sdk:8.0 AS buildWORKDIR /src COPY ["ZeroTrustApi.csproj" , "./" ] RUN dotnet restore "ZeroTrustApi.csproj" COPY . . RUN dotnet publish "ZeroTrustApi.csproj" -c Release -o /app/publish FROM base AS finalWORKDIR /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 - 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 沒設好會在啟動時吃 NullReferenceException;AspNetCoreRateLimit 必須先 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)
]