24. 安全加固:国密、脱敏、验证码、敏感词
一句话定位:这一篇把 Admin.NET 里五套独立的安全设施串起来——国密加解密(SM2/SM4 连接串与密码)、数据脱敏([DataMask] + Mask 系列 Converter)、图形验证码、敏感词过滤、开放接口签名/APIKey 防重放。
一、国密 CryptogramHelper
文件:Admin.NET.Core/Utils/GM/CryptogramHelper.cs
1.1 配置
// Admin.NET.Application/Configuration/App.json
"Cryptogram": {
"CryptoType": "SM2", // 密码加密算法:MD5、SM2、SM4
// 业务系统记得改密匙(修改密匙要重新生成数据库/账号表),
// 通过接口 http://localhost:5005/api/sysCommon/smKeyPair 获取
"PublicKey": "04851D329AA3E38C2E7670AFE70E6E70E92F8769CA27C8766B12209A0FFBA4493B603EF7A0B9B1E16F0E8930C0406EA0B179B68DF28E25334BDEC4AE76D907E9E9",
"PrivateKey": "3A61D1D30C6302DABFF36201D936D0143EEF0C850AF28C5CA6D5C045AF8C5C8A"
}public class CryptogramHelper
{
public static readonly string CryptoType = App.GetConfig<string>("Cryptogram:CryptoType");
public static readonly string PublicKey = App.GetConfig<string>("Cryptogram:PublicKey");
public static readonly string PrivateKey = App.GetConfig<string>("Cryptogram:PrivateKey");
public static readonly string SM4_key = "0123456789abcdeffedcba9876543210";
public static readonly string SM4_iv = "595298c7c6fd271f0402f804c33d3f66";
}1.2 API 一览
| 方法 | 用途 |
|---|---|
Encrypt(plainText) | 按 CryptoType 自动选 MD5 / SM2 / SM4 |
Decrypt(cipherText) | 对应解密(MD5 不可逆,走原样返回) |
SM2Encrypt / SM2Decrypt | SM2 非对称加解密(公加密、私解密) |
SM4EncryptECB / SM4DecryptECB | SM4 对称 ECB |
SM4EncryptCBC / SM4DecryptCBC | SM4 对称 CBC(带 IV) |
GetSmKeyPair() | 生成 SM2 密钥对(返回 SmKeyPairOutput) |
GetSm4Key(identity) | 把 Base64Url 字符串转成 16 字节 SM4 密钥 |
1.3 数据库连接串加密(最常用)
// Admin.NET.Core/SqlSugar/SqlSugarSetup.cs SetDbConfig()
// 解密数据库连接串
if (config.DbSettings.EnableConnEncrypt)
config.ConnectionString = CryptogramHelper.SM2Decrypt(config.ConnectionString);// Admin.NET.Application/Configuration/Database.json
"DbConnection": {
"EnableConsoleSql": true,
"ConnectionConfigs": [
{
"ConfigId": "1300000000001",
"DbType": "MySql",
"EnableConnEncrypt": true, // ← 打开连接串加密
"ConnectionString": "04SM2加密后的密文", // ← 用加密接口生成
...
}
]
}生成密文:调用 CryptogramHelper.SM2Encrypt(连接串)(可用框架自带的 /api/sysCommon/smKeyPair 生成密钥对,配置注释里也标注了这一点),把结果填进 ConnectionString。
框架已自带
ApiDescriptionSettings里的/api/sysCommon/smKeyPair(注释里也提到了),业务别重复造。
1.4 SqlFunc 层面的解密
SetDbConfig 注册了自定义 SQL 函数 SqlFuncDecrypt(UniqueMethodName = "SqlFuncDecrypt"),可在查询表达式里对密文列解密:
.Select(u => new { Phone = SqlSugarSetup.SqlFuncDecrypt(u.PhoneCipher) })⚠️ 该实现是先取值再在内存解密,不是数据库函数下推。大表上拿它做 WHERE 条件等于全表扫描,慎用。
二、数据脱敏
2.1 特性 [DataMask]
// Admin.NET.Core/Attribute/DataMaskAttribute.cs
[AttributeUsage(AttributeTargets.Property)]
public class DataMaskAttribute : Attribute
{
public DataMaskAttribute(int startIndex, int length) { ... } // 保留前 startIndex 位,打码 length 位
public string Mask(string input)
{
if (string.IsNullOrEmpty(input) || input.Length <= StartIndex) return input;
var maskedLength = Math.Min(Length, input.Length - StartIndex);
var maskStr = new string(MaskChar, KeepLength ? maskedLength : Math.Min(4, maskedLength));
return input[..StartIndex] + maskStr
+ ((StartIndex + maskedLength < input.Length) ? input[(StartIndex + maskedLength)..] : "");
}
}public class UserOutput
{
[DataMask(3, 4)] public string Phone { get; set; } // 138****5678
[DataMask(1, 1)] public string RealName { get; set; } // 张*
}两个生效入口:
// ① 单对象:Admin.NET.Core/Extension/ObjectExtension.cs
public static T MaskSensitiveData<T>(this T obj) where T : class;
// 反射缓存带 [DataMask] 的 string 属性,Parallel.ForEach 就地改写
// ② 分页:Admin.NET.Core/SqlSugar/SqlSugarPagedList.cs
public static async Task<SqlSugarPagedList<TEntity>> ToPagedListDataMaskAsync<TEntity>(
this ISugarQueryable<TEntity> query, int pageIndex, int pageSize) where TEntity : class;
// 内部 items.ForEach(u => u.MaskSensitiveData());服务层用法——把 ToPagedListAsync 换成 ToPagedListDataMaskAsync:
[DisplayName("用户分页")]
public async Task<SqlSugarPagedList<UserOutput>> Page(PageUserInput input)
=> await _rep.AsQueryable().Select<UserOutput>()
.ToPagedListDataMaskAsync(input.Page, input.PageSize);2.2 JsonConverter 系列(Output 专用)
Admin.NET.Core/Attribute/JsonConverterSystemText.cs:
| Converter | 行为 | 应用点 |
|---|---|---|
MaskConverter | 通用掩码(value.Mask()) | Service/User/Dto/UserOutput.cs |
MaskNameConverter | 姓名掩码(MaskName()) | 姓名类字段 |
MaskIdCardConverter | 身份证掩码 | UserOutput.IdCardNum |
MaskEmailConverter | 邮箱掩码 | UserOutput.Email、OAuthUserOutput.Email |
public class UserOutput
{
[JsonConverter(typeof(MaskConverter))] public string Phone { get; set; }
[JsonConverter(typeof(MaskIdCardConverter))] public string IdCardNum { get; set; }
[JsonConverter(typeof(MaskEmailConverter))] public string Email { get; set; }
}两种方案怎么选:
[DataMask]:需要显式调用MaskSensitiveData()/ToPagedListDataMaskAsync()才生效,好处是"明文版本"还能保留给内部逻辑JsonConverter:只要对象参与序列化就无条件生效,适合 Output DTO,但同一个 DTO 给后台管理用时也会被打码
Read 方法都原样返回(
reader.GetString()),所以脱敏只发生在输出方向,入参不受影响。
2.3 统一结果里的脱敏(被注释掉的伏笔)
// AdminNETResultProvider.Result() 中:
//// 统一返回值脱敏处理
//if (data?.GetType() == typeof(String))
// data = App.GetRequiredService<ISensitiveDetectionProvider>().ReplaceAsync(data.ToString(), '*')...这是把敏感词过滤接入统一出口的预留代码,按需解开即可(见第四节)。
三、验证码
3.1 注册与配置
// Admin.NET.Web.Core/Startup.cs
services.AddCaptcha();// Admin.NET.Application/Configuration/Captcha.json(节选)
"CaptchaOptions": {
"CaptchaType": 10, // 验证码类型 0~11
"CodeLength": 1, // 长度(算术表达式时代表操作数个数)
"ExpirySeconds": 60, // 过期秒数
"IgnoreCase": true, // 比较时忽略大小写
"ImageOption": { "Animation": true, "Width": 150, "Height": 50,
"InterferenceLineCount": 3, "FontFamily": "kaiti" },
"RateLimit": { "Enabled": true, "WindowSeconds": 60, "MaxRequests": 5 }
}完整字段见 Admin.NET.Application/Configuration/Captcha.json。
3.2 接口
// Admin.NET.Core/Service/Auth/SysAuthService.cs
[SuppressMonitor]
[DisplayName("获取验证码")]
[AllowAnonymous]
public CaptchaOutput GetCaptcha()
{
var codeId = YitIdHelper.NextId().ToString(); // 雪花 Id 做验证码 Id
var captcha = _captcha.Generate(codeId);
var expirySeconds = App.GetOptions<CaptchaOptions>()?.ExpirySeconds ?? 60;
return new CaptchaOutput { Id = codeId, Img = captcha.Base64, ExpirySeconds = expirySeconds };
}返回 { id, img(base64), expirySeconds },前端把 id 与用户输入一起回传。
3.3 校验
// 登录时(受租户开关 tenant.Captcha 控制)
if (tenant.Captcha == true && !_captcha.Validate(input.CodeId.ToString(), input.Code))
throw Oops.Oh(ErrorCodeEnum.D0008); // 验证码错误
// 注册时
if (!_captcha.Validate(input.CodeId.ToString(), input.Code)) throw Oops.Oh(ErrorCodeEnum.D0008);
_captcha.Generate(input.CodeId.ToString()); // 用完立即刷新,防止重放同一个码接入自己的接口,标准三行:
if (!_captcha.Validate(input.CodeId.ToString(), input.Code)) throw Oops.Oh(ErrorCodeEnum.D0008);
_captcha.Generate(input.CodeId.ToString()); // 用完立即刷新,防止重放同一个码
// ... 业务逻辑四、敏感词过滤
// Admin.NET.Web.Core/Startup.cs:122
services.AddSensitiveDetection();词库作为嵌入资源打包:
<!-- Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj -->
<ItemGroup>
<EmbeddedResource Include="sensitive-words.txt">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</EmbeddedResource>
</ItemGroup>使用:
public class BizCommentService : IDynamicApiController, ITransient
{
private readonly ISensitiveDetectionProvider _sensitiveDetection;
public BizCommentService(ISensitiveDetectionProvider sensitiveDetection) => _sensitiveDetection = sensitiveDetection;
[DisplayName("发布评论")]
public async Task Publish(CommentInput input)
{
var content = await _sensitiveDetection.ReplaceAsync(input.Content, '*'); // 命中词替换为 *
await _rep.InsertAsync(new BizComment { Content = content });
}
}常用 API:ReplaceAsync(text, maskChar) / GetKeywordsAsync(text)(只检测不改写)。
扩展词库:直接改
Admin.NET.Web.Entry/sensitive-words.txt后重新生成(嵌入资源会重新打包)。用它过滤的是内容安全,别指望它防 XSS——输出仍需正确编码。
五、开放接口:APIKey 与签名防重放
两个认证方案都在启动时注册:
// Admin.NET.Web.Core/Startup.cs
services.AddAuthentication(...JWT...)
.AddSignatureAuthentication(options => // Signature 方案
{
options.Events = SysOpenAccessService.GetSignatureAuthenticationEventImpl();
})
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationHandler.AuthenticationScheme, options => { }); // ApiKey 方案5.1 APIKey(X-API-KEY)
核心逻辑(Admin.NET.Core/ApiKeyAuth/ApiKeyAuthenticationHandler.cs):
public const string AuthenticationScheme = "ApiKey";
var apiKey = Request.Headers["X-API-KEY"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(apiKey)) return await AuthenticateResultFailAsync("API-KEY 不能为空", sw, ctx);
var acsInfo = await repAcs.AsQueryable().Includes(u => u.BindUser)
.Includes(u => u.BindUser, p => p.SysOrg)
.FirstAsync(c => c.AccessSecret == apiKey);
if (acsInfo == null) return await AuthenticateResultFailAsync("API-KEY 无效", sw, ctx);
var identity = new ClaimsIdentity(AuthenticationScheme);
identity.AddClaims([
new Claim(ClaimConst.UserId, acsInfo.BindUserId + ""),
new Claim(ClaimConst.TenantId, acsInfo.BindTenantId + ""),
new Claim(ClaimConst.Account, acsInfo.BindUser.Account + ""),
new Claim(ClaimConst.RealName, acsInfo.BindUser.RealName),
new Claim(ClaimConst.AccountType, ((int)acsInfo.BindUser.AccountType).ToString()),
new Claim(ClaimConst.OrgId, acsInfo.BindUser.OrgId + ""),
new Claim(ClaimConst.TokenVersion, "1")
]);
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), AuthenticationScheme));认证成功后 MaskApiKey(保留前 4 后 4)会把经过脱敏的 key 写进 Items[OpenAccessKey],供 LoggingMonitor 落日志。
调用方:
curl -X POST 'https://api.xxx.com/api/bizOrder/submit' \
-H 'X-API-KEY: 9d7f1c2e-3b4a-4f8e-9a1b-0c5d6e7f8a9b' \
-H 'Content-Type: application/json' \
-d '{"sku":"A-01","qty":2}'注意
X-API-KEY里放的是AccessSecret明文,必须走 HTTPS。框架在日志里会做脱敏(MaskApiKey:长度 ≤8 全掩码,否则保留前 4 后 4)。
5.2 Signature(防重放)
请求头四个必填字段:
| Header | 说明 |
|---|---|
accessKey | 身份标识(明文,对应 SysOpenAccess.AccessKey) |
timestamp | Unix 时间戳(秒,也兼容毫秒,>9999999999 自动按毫秒解析) |
nonce | 唯一随机数 |
sign | HMACSHA256(accessSecret, 签名原文) 的 Base64 |
签名原文:
private static string GetMessageForSign(HttpContext context)
{
var method = context.Request.Method; // 请求方法(大写)
var url = context.Request.Path; // 路径,不含协议/域名/查询参数
var accessKey = context.Request.Headers["accessKey"].FirstOrDefault();
var timestamp = context.Request.Headers["timestamp"].FirstOrDefault();
var nonce = context.Request.Headers["nonce"].FirstOrDefault();
return $"{method}&{url}&{accessKey}&{timestamp}&{nonce}";
}
private static string SignData(byte[] secret, string data)
{
using HMAC hmac = new HMACSHA256();
hmac.Key = secret;
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(data)));
}完整校验链:
① 四个 Header 非空检查 → 缺失直接 Fail 并写 Items[AuthenticateFailMsgKey]
② timestamp 合法 + 时间偏差检查:|requestDate - utcNow| <= AllowedDateDrift(默认 5 分钟)
③ Events.GetAccessSecret(accessKey) → 取不到 accessSecret = "accessKey 无效"
④ 服务端重算 sign 并比对 → 不等则 "sign 无效的签名"
⑤ 重放检测:KeyOpenAccessNonce = sys_open_access_nonce:{accessKey}|{timestamp}|{nonce}
已存在 → "重复的请求";否则缓存,TTL = AllowedDateDrift * 2
⑥ Events.Validated → 把 BindUser 的 UserId/TenantId/AccountType/TokenVersion 写进 Claims客户端示例:
import CryptoJS from 'crypto-js';
const accessKey = 'your-access-key';
const accessSecret = 'your-access-secret'; // 只本地使用,绝不传输
export function buildAuthHeaders(method: string, path: string) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomUUID();
const message = `${method.toUpperCase()}&${path}&${accessKey}&${timestamp}&${nonce}`;
const sign = CryptoJS.HmacSHA256(message, accessSecret).toString(CryptoJS.enc.Base64);
return { accessKey, timestamp, nonce, sign, 'Content-Type': 'application/json' };
}
await fetch('https://api.xxx.com/api/bizOrder/submit', {
method: 'POST',
headers: buildAuthHeaders('post', '/api/bizOrder/submit'),
body: JSON.stringify({ sku: 'A-01', qty: 2 })
});开放接口的管理服务:Admin.NET.Core/Service/OpenAccess/SysOpenAccessService.cs(DTO 在同目录 Dto/),事件实现:
public static SignatureAuthenticationEvent GetSignatureAuthenticationEventImpl()
{
return new SignatureAuthenticationEvent
{
OnGetAccessSecret = context => { /* 用 AccessKey 查 SysOpenAccess 返回 AccessSecret */ },
OnValidated = context => { /* 把 BindUser 的 UserId/TenantId/AccountType/TokenVersion 写成 Claim */ }
};
}六、综合示例:验证码 + 国密 + 脱敏
[AllowAnonymous]
[DisplayName("会员注册")]
public async Task<long> Register(MemberRegisterInput input)
{
// ① 验证码:用完即废
if (!_captcha.Validate(input.CodeId.ToString(), input.Code)) throw Oops.Oh(ErrorCodeEnum.D0008);
_captcha.Generate(input.CodeId.ToString());
// ② 敏感词:昵称过滤
var nickName = await _sensitive.ReplaceAsync(input.NickName, '*');
// ③ 国密:联系方式加密入库(CryptoType 决定实际算法,明文永不出服务层)
await _memberRep.InsertAsync(new BizMember
{
NickName = nickName,
PhoneCipher = CryptogramHelper.Encrypt(input.Phone),
IdCardCipher = CryptogramHelper.Encrypt(input.IdCardNum)
});
return 0;
}## 七、步骤清单
- 国密:改
App.json的Cryptogram(尤其是 PublicKey/PrivateKey),用/api/sysCommon/smKeyPair生成新密钥对,注意换密钥=历史密文全部失效 - 连接串加密:
Database.json打开EnableConnEncrypt,把 SM2 加密后的串填进ConnectionString - 脱敏:Output DTO 上加
[DataMask(start, len)]或[JsonConverter(typeof(MaskXxxConverter))],分页查询改用ToPagedListDataMaskAsync - 验证码:确认
services.AddCaptcha()已注册,前端接/api/sysAuth/captcha,业务里用_captcha.Validate(codeId, code)并在通过后立即Generate刷新 - 敏感词:确认
services.AddSensitiveDetection()已开,改sensitive-words.txt后重新生成 - 开放接口:在
SysOpenAccess里建身份,按accessKey配签名用的AccessSecret;选择 APIKey(简单)或 Signature(防重放) - 收尾:所有匿名接口(
[AllowAnonymous])逐一复核——它们会绕过第 14 篇的权限校验
⚠️ 注意事项 / 常见坑
EnableConnEncrypt = true后务必确认密文是用同一对密钥加密的,否则启动时抛出D0010 密匙不匹配,且堆栈掩盖了真实原因。- 换密钥 = 数据报废:所有用
CryptogramHelper.Encrypt存过的密码/连接串都无法解开。先解密迁移,再换 key。而且注释里明说了"修改密匙要重新生成数据库/账号表"。 - MD5 不可逆:
Decrypt遇到CryptoType=MD5时走的是最后的return cipherText(原样返回),不是抛异常。别被这个行为骗了。 [DataMask]只有调了扩展方法才生效:换成ToPagedListAsync就彻底不脱敏。建议封装一层统一的分页方法,避免漏改。MaskConverter是序列级全局生效:同一个 DTO 被管理端接口复用时也会被脱敏,需要明文就得再建一个ManagerOutput。Parallel.ForEach改同一个对象:MaskSensitiveData并行设置了自身属性,对象若被其它线程同时读取会看到中间态;单次请求内使用没问题。- 验证码用完必须
Generate刷新:否则同一个 codeId+code 可以被无限次重放(SysAuthService.UserRegistration里就补了这一句)。 RateLimit是全局计数:MaxRequests=5 / WindowSeconds=60针对的是验证码生成接口,压测或前端重试会把正常用户挡出去。- 敏感词只处理文本内容:上传的文件、图片 OCR、富文本 HTML 都不会被自动过滤(除非自己调用)。
- 签名里的
url是Request.Path:不含查询串,也不含网关重写后的原始路径。网关改写了 URI 会让客户端算出的签名对不上。 nonce缓存 TTL =AllowedDateDrift * 2:默认 10 分钟,客户端重试必须换 nonce,否则被判"重复的请求"。timestamp自动兼容秒/毫秒(阈值9999999999),但别在不同接口混用两种单位。AuthenticationScheme是硬编码字符串:"ApiKey"与"Signature",前端/第三方配置别拼错。- 开放接口绕过登录但没绕过授权:认证成功后会写
ClaimConst.TokenVersion,JwtHandler仍会校验 TokenVersion 与租户有效期,用户被停用照样掉线。 - 失败信息会被写进
Items[AuthenticateFailMsgKey]:由AdminNETResultProvider.OnResponseStatusCodes回显为 401 的 message。生产环境若不想泄漏原因(比如"API-KEY 无效"帮助攻击者探测),请改写这段回显。
小结
- 国密:
CryptogramHelper(SM2/SM4/MD5),配置在App.json的Cryptogram;连接串用DbSettings.EnableConnEncrypt+SM2Decrypt - 脱敏:
[DataMask(起, 长)]配MaskSensitiveData()/ToPagedListDataMaskAsync();或直接用MaskConverter/MaskIdCardConverter/MaskEmailConverter/MaskNameConverter - 验证码:
services.AddCaptcha()+Configuration/Captcha.json,GetCaptcha()返回{id, img, expirySeconds},Validate通过后记得Generate刷新 - 敏感词:
services.AddSensitiveDetection()+sensitive-words.txt(嵌入资源),API 是ISensitiveDetectionProvider - 开放接口:
X-API-KEY(简单)或Signature(四 Header + HMACSHA256 + nonce 防重放),两者都在Startup.cs注册,最终都会带上完整 Claims 参与后续授权