NET8部署Kestrel服务配置TLS协议验证实战
概述
- 安全套接字层超文本传输协议HTTPS。HTTPS(全称:Hyper Text Transfer Protocol over Secure
Socket Layer 或 Hypertext Transfer Protocol
Secure,超文本传输安全协议),是以安全为目标的HTTP通道。HTTPS并不是一个新协议,而是HTTP+SSL(TLS)。- SSL 是“Secure Sockets Layer”的缩写,中文叫做“安全套接层”。标准化之后SSL被改为 TLS(Transport Layer Security传输层安全协议)ssl协议可以说是https的核心,对数据进行加密就是通过ssl,而ssl协议既不是工作在应用层也不是工作在传输层而是工作在他们之间的ssl。
证书链
证书链是证书列表(通常以终端实体证书开头)后跟一个或多个CA证书(通常最后一个是自签名证书),具有以下属性:
- 每个证书(最后一个证书除外)的颁发者与列表中下一个证书的主题相匹配。
- 每个证书(最后一个证书除外)都应该由链中下一个证书对应的密钥签名(即一个证书的签名可以使用包含在后续证书中的公钥来验证)。
- 列表中的最后一个证书是信任锚:您信任的证书,因为它是由一些值得信赖的程序交付给您的。信任锚是CA证书(或更准确地说,是CA的公共验证密钥),被依赖方用作路径验证的起点。
在RFC5280中,证书链或信任链被定义为“证书路径”。换句话说,信任链是指您的SSL证书以及它如何链接回受信任的证书颁发机构。为了使SSL证书可信,它必须可以追溯到它被签署的信任根,这意味着链中的所有证书——服务器、中间证书和根证书,都需要得到适当的信任。
单向验证
准备条件
- 数字证书:受信任根(CA)证书、服务端(End-Entity)证书
- 创建ASP.NET Core Web Api项目
appsettings相关配置
在开始之前,预先配置好开发环境与生产环境启用TLS协议的配置:
// launchSettings[开发环境]
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
//"launchUrl": "swagger",
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
// 移除Sugger路由前缀(第1步)
//"launchUrl": "swagger",
//默认时http及https协议可访问端口
"applicationUrl": "https://*:5001;http://*:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
// appsettings[生产环境]
{
"Kestrel": {
"Endpoints": {
"HttpLoopback": {
"Url": "http://*:9990"
},
"HttpsInlineCertFile": {
"Url": "https://*:9991",
//加载服务端证书,默认为编译根路径下
"Certificate": {
"Path": "serverCert.pfx",
"Password": "[certPwd]"
}
}
}
},
//允许访问主机域名,星号代表任意域名前缀,外加WebApiSrvIP
"AllowedHosts": "*.mes.com;10.0.5.76"
}
Swagger服务配置
1、解决DateTime转换JSON问题
引入Neget包
Microsoft.AspNetCore.Mvc.NewtonsoftJson 8.0
builder.Services.AddControllers()
.AddNewtonsoftJson(op =>
{
op.SerializerSettings.ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver();
op.SerializerSettings.DateFormatString = "yyyy-MM-dd HH:mm:ss";
});
2、OpenApi Swagger配置
(1) 服务配置
builder.Services.AddSwaggerGen(options =>
{
//分为2份接口文档
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Mes数据采集Api",
Version = "v1",
Contact = new OpenApiContact()
{
Name = "morliz",
Email = "morliz@live.cn",
Url = null
},
Description = "MES数据采集解决方案"
});
});
// register http client Instance.
builder.Services.AddHttpClient();
(2) 启动服务
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
// 配置此处时,仅在开发环境暴露Swagger文档
//app.UseSwagger();
//app.UseSwaggerUI(options =>
//{
// options.SwaggerEndpoint("/swagger/v1/swagger.json", "Mes数据采集Api");
// options.RoutePrefix = string.Empty;
//});
}
else
{
app.UseExceptionHandler("/Error");
//Adds the Strict-Transport-Security header.
app.UseHsts();
}
//所有环境均暴露,建议发布到生产环境时放在开发环境中
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "Mes数据采集Api");
//移除Swagger路由前缀(第2步)
options.RoutePrefix = string.Empty;
});
Kestrel服务配置
单向身份验证时,最核心的就是启用TSL协议时证书配置:
- 服务端(End-Entity)证书的配置
- 受信任根(CA)证书的安装
(1) 服务端(End-Entity)证书
除了在本文开头通过appsettings.json部署Kestrel服务外,还可以通过编程方式实现:
builder.WebHost.ConfigureKestrel(serverOption =>
{
serverOption.ListenAnyIP(9990);
serverOption.ListenAnyIP(9991, listenOptions =>
{
listenOptions.UseHttps("serverCert.pfx", "[certPwd]");
});
});
// 类似AllowedHosts多域名请求配置
app.UseCors(cpBuilder =>
{
cpBuilder.WithOrigins("https://*.mes.com", "https://10.0.5.76")
.AllowAnyHeader()
.AllowAnyMethod();
});
app.UseHttpsRedirection();
//捕获HttpClient上下文请求头
app.Use(async (context, next) =>
{
foreach (var header in context.Request.Headers)
{
Console.WriteLine($"Header: {header.Key} = {header.Value}");
}
Console.WriteLine("======================================================");
await next();
});
(2) 受信任根(CA)证书
当通过浏览器访问域名时,Tls验证握手成功后获取到服务器端证书链信息,同会验证两处:
- 请求地址头是否在允许列表(AllowedHosts或Cors)
- 通过证书链(PEM encoded chain),检查当前端[受信任根证书颁发机构]下是否有该证书的CA证书
故rootCert.cer根证书需要预先导入到证书管理器中:
当请求地址头不属性列表时,浏览器则直接返回:
当请求地址头属性列表时,浏览器则验证该地址头是否属于服务端证书[使用者可选名称]列表名:
不属于时会提示建立的连接不安全:
此处把IP放入AllowedHosts或Cors列表,仅仅是为了做此测试。当完全符合证书请求头域名时:
至此,整个环节测试成功!最后重点还是说明一下,上述均是在生产环境中完成整个测试过程:
编程调用HttpClient
由于是单向验证,系统自会检查服务器证书链是否在请求端的可信任根列表中,当然也可以自定义添加更为复杂的验证逻辑。
class Program
{
static async Task Main(string[] args)
{
//httpClient消息处理句柄
var handler = new HttpClientHandler
{
SslProtocols = SslProtocols.Tls12,
//服务端证书验证回调
ServerCertificateCustomValidationCallback = ValidateCertificateChain
};
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://webapi.mes.com:9991")
};
var response = await client.GetAsync("/WeatherForecast");
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine(content);
Console.WriteLine("Hello World!");
Console.ReadKey();
}
//回调实现
private static bool ValidateCertificateChain(HttpRequestMessage request, X509Certificate2 certificate,
X509Chain? chain, SslPolicyErrors errors)
{
//协议策略验证的状态
//从服务端传递过来的,比如请求连接Url或Port搞错了,此处都可能报错
if (errors != SslPolicyErrors.None)
Console.WriteLine($"error:{errors}");
// Custom validation logic.
var success = chain.Build(certificate);
// Output trust chain information.
Console.WriteLine("Chain built. Is valid: " + success);
if (chain.ChainStatus.Length > 0)
foreach (X509ChainStatus error in chain.ChainStatus)
Console.WriteLine($"{error.Status}: {error.StatusInformation}");
return success;
}
}
调试结果
双向验证
在单向还是双向验证当中,很容易让人误导其验证就是身份验证。其实不然,证书验证过程其实仍停留在双方身份的确认交互上。主要的内容体现如下:
- 客户端->必须提供证书
- 服务器->在回调函数中获取并检查客户端证书信息:
- 证书有效性:证书签名及有效期等
- 证书链的根信息是否符合
- 证书链验证条件标志检查(可选)
- 证书CRL吊销列表(可选):CDP检查、OCSP查询等
- 可扩展等
1、Kestrel服务配置
在双向证书验证当中,如果仍使用appsetting配置来实现,则灵活性就不高了。为啥呢?
//比如appsetting配置双向验证
"Kestrel": {
"Endpoints": {
"HttpLoopback": {
"Url": "http://*:9990"
},
"HttpsInlineCertFile": {
"Url": "https://*:9991",
"SslProtocols": [ "Tls12" ],
"Certificate": {
"Path": "serverCert.pfx",
"Password": [certPwd]"
},
"ClientCertificateMode": "RequireCertificate",
"ClientCertificateValidation": "ChainTrust",
"ClientCertificate": {
"AllowedCertificateTypes": "All",
"ValidateCertificateUse": true,
"ValidateCertificateRevocation": false,
"ValidateValidityPeriod": true,
"RevocationMode": "NoCheck",
"AlowedIssuers": [
"Kingser RSA Root Certificate Authority 2024"
]
}
}
}
},
"AllowedHosts": "*.mes.com"
这样也能在客户端请求时,索要证书并检查证书链有效性等验证。但缺点就是系统仅索要请求证书,是否过期以及能不能找到根证书(可信任)。这似乎听起来好象没毛病啊?但如果请求证书在服务端的根下面能找到可信任证书,但是不是和服务端证书同属一个根证书,则检查不了。换句话说,只能要求客户端能提供有效的证书,但证书链上是否同信任根无法满足。(虽然仍配置了AlowedIssuers根证书名称,检查仍需在代码中实现)
所以这种配置笔者觉得很不友好,还不如干撸:
builder.WebHost.ConfigureKestrel(serverOption =>
{
serverOption.ListenAnyIP(9990);
serverOption.ListenAnyIP(9991, listenOptions =>
{
var srvCert = new X509Certificate2("serverCert.pfx", "[certPwd]");
var httpsOptions = new HttpsConnectionAdapterOptions
{
//服务端证书
ServerCertificate = srvCert,
//客户端证书模式:索要
ClientCertificateMode = ClientCertificateMode.RequireCertificate,
SslProtocols = SslProtocols.Tls12,
//在身份验证时是否检查证书吊销列表(CRL)
//CheckCertificateRevocation = false,
ClientCertificateValidation = (clientCert, chain, errors) =>
{
if (errors != SslPolicyErrors.None)
Console.WriteLine($"error:{errors}");
if (srvCert != null)
{
var cieIssuer = clientCert.Issuer.Split(',')
.Where(element => element.StartsWith(" CN=")).First().Replace(" CN=", "");
//Console.WriteLine($"The parent CA certificate issuer name is {issuer}.");
var srvIssuer = srvCert.Issuer.Split(',')
.Where(element => element.StartsWith(" CN=")).First().Replace(" CN=", "");
//Console.WriteLine($"[srvCert] The parent CA certificate issuer name is {srvIssuer}.");
if (cieIssuer != srvIssuer) return false;
}
// Custom validation logic.
var success = chain.Build(clientCert);
// Output certificate chain information.
if (chain.ChainStatus.Length > 0)
foreach (X509ChainStatus error in chain.ChainStatus)
Console.WriteLine($"{error.Status}: {error.StatusInformation}");
return success;
},
};
listenOptions.UseHttps(httpsOptions);
});
});
对证书的检查验证,仅作了客户端的发行者和服务端的发行者是否一致,最简单的检查。如果严谨一点可以获取客户端的授权密钥标识符AKI的值(rawData[]类型),是否与服务器端的授权密钥标识符AKI值一致,以确认它们同属一个受信任根(CA)证书。
2、证书验证
和单向验证是一样的。受信任根(CA)证书,一般仅是在Web浏览器请求访问时,才会检查请求证书的证书链是否有效(而编程调用时,则不受约束,由HttpClientHandler句柄具体的检查实现)
另外,需要在host中需要先添加域名解析(此处是假域名,仅做开发测试使用):
10.0.5.76 webapi.mes.com
当通过浏览器请求请,可能会有此提示:
为什么说可能?系统会先检查[证书管理器]中【当前用户/个人】证书列表,是否存在带有私钥的证书(证书图标有把小钥匙),才能被浏览器识别。说白一点,浏览器需要有公、私对钥的证书(Pkcs#12),才能满足证书链的检查。
然后,才是检查请求地址是否在被允许的列表:
如果没有限定主机允许列表(“AllowedHosts”: “*”),那允许访问成功但是浏览器认为是不安全的连接:
当请求域名和证书同时都选择正确时,则建立TSL连接才是安全有效的:
编程调用HttpClient
和单向验证区别不大,多了需要提交请求的证书:
class Program
{
static async Task Main(string[] args)
{
var handler = new HttpClientHandler
{
SslProtocols = SslProtocols.Tls12,
ServerCertificateCustomValidationCallback = ValidateCertificateChain
};
//add client certificate.
var clientCert = new X509Certificate2(Path.Combine(Directory.GetCurrentDirectory(),
"clientCert.pfx"), "[certPwd]");
handler.ClientCertificates.Add(clientCert);
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://webapi.mes.com:9991")
};
var response = await client.GetAsync("/WeatherForecast");
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine(content);
Console.WriteLine("Hello World!");
Console.ReadKey();
}
private static bool ValidateCertificateChain(HttpRequestMessage request, X509Certificate2 certificate,
X509Chain? chain,
SslPolicyErrors errors)
{
if (errors != SslPolicyErrors.None)
Console.WriteLine($"error:{errors}");
// Custom validation logic.
var success = chain.Build(certificate);
// 输出链信息
Console.WriteLine("Chain built. Is valid: " + success);
if (chain.ChainStatus.Length > 0)
foreach (X509ChainStatus error in chain.ChainStatus)
Console.WriteLine($"{error.Status}: {error.StatusInformation}");
return success;
}
}
测试结果就不放了,和单向验证一样。在这里最后总结一下:
- 服务端对证书身份的验证
- 验证的是客户端证书
- 回调函数:ClientCertificateValidation
- 客户端对证书身份的验证
- 验证的是服务端证书
- 回调函数:ServerCertificateCustomValidationCallback
在证书链策略上其实还有非常多的玩法,比如CustomTrustStore和ExtraStore的自定义信任根存储,不使用默认的Windows证书存储器等等。感兴趣的客官自行发掘吧~谢谢翻阅