当你把内部 API 开放给第三方系统调用时,传输安全 往往是第一个要考虑的问题。仅仅给调用方一个账号密码(Basic Auth),明文走 HTTP,会立刻面对两个经典风险:
- 请求被篡改:中间人修改了请求参数或 body;
- 请求被重放:黑客抓包后原封不动再发 N 次,制造脏数据或打爆接口。
解决思路业界早有共识:用密钥对关键信息做签名,再在服务端统一校验。SwitchData 项目在 Business/Web/Filters + Business/Web/Services 两层里,把这套方案实现得非常规范。
一、整体架构:三层解耦
SwitchData 把整个签名验证拆成三个文件,各司其职:
| 文件 | 作用 |
|---|---|
ApiSignAuthorizeAttribute |
对外暴露的 Attribute,贴 Controller / Action 就能开签名校验 |
ApiSignAuthorizationFilter |
真正执行的 Filter,在 Authorization 管道阶段运行 |
ApiSignValidator |
纯静态校验器,处理具体的签名算法 + 时间窗口判断 |
这种"Attribute → Filter → Validator"的三层拆分,让单元测试很容易做(直接测 Validator),同时注册方式也简洁:
// Controller 上贴一行就行
[ApiSignAuthorize]
public class InvocationController : ApiBaseController { ... }
二、Attribute:TypeFilter + Order = 最简洁的注册方式
很多人写自定义 Filter 会先在 Program.cs 里全局 AddControllers(opt => opt.Filters.Add<...>()),然后用 [TypeFilter] 做实例化。SwitchData 则把这一步直接封装在 Attribute 自身里:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class ApiSignAuthorizeAttribute : TypeFilterAttribute
{
public ApiSignAuthorizeAttribute() : base(typeof(ApiSignAuthorizationFilter))
{
Order = -1000; // 越早执行越好,挡在业务逻辑之前
}
}
两个关键点:
- 继承
TypeFilterAttribute,这样贴了[ApiSignAuthorize]的 Action 就会由 DI 容器来创建ApiSignAuthorizationFilter实例,需要啥依赖直接走构造函数; Order = -1000:ASP.NET Core 多个 Filter 同时命中时,Order 越小越先执行,签名校验属于"前置关卡",必须比权限、日志等其他 Filter 更早跑。
三、Filter:管道阶段 + AllowAnonymous 跳过
ApiSignAuthorizationFilter 实现 IAsyncAuthorizationFilter,这个接口的核心就是下面这一个方法:
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
if (IsAllowAnonymous(context)) return;
var result = await ApiSignValidator.Validate(context.HttpContext);
if (!result.success)
{
context.Result = new JsonResult(result)
{
StatusCode = StatusCodes.Status401Unauthorized
};
}
}
这里最重要的是 IsAllowAnonymous 判断:如果某个 Action 标了 [AllowAnonymous],就跳过签名校验,否则登录接口、健康检查这种本身就不需要签名的 Action 会被自己挡掉。
判断方式也很简单——用 ASP.NET Core 的 Endpoint 元数据:
private static bool IsAllowAnonymous(AuthorizationFilterContext context)
{
var endpoint = context.HttpContext.GetEndpoint();
if (endpoint == null) return false;
return endpoint.Metadata.GetMetadata<AllowAnonymousAttribute>() != null;
}
为什么不用传统的
context.Filters.Any(f => f is AllowAnonymous... )?因为 ASP.NET Core 3.x+ 把 Attribute 统一放进了 Endpoint Metadata,不再保证context.Filters里一定有;GetEndpoint()是更可靠、性能更好的写法。
四、Validator:签名 + 时间戳 + 密钥三件套
校验的核心在 ApiSignValidator.Validate,它从请求头读取三个字段:
| 头字段 | 含义 | 示例 |
|---|---|---|
user-id |
调用方账号 | sys_001 |
timestamp |
请求发起时间(支持 Unix 毫秒或字符串) | 1731137927000 / 2024-11-09 12:23:40 |
sign |
客户端按规则算出来的签名 | 9B15FE814179CFAE7BA6697E7AEA37F0 |
4.1 时间戳:5 分钟重放窗口
SwitchData 给 timestamp 开了 5 分钟窗口:
const int TimeSpanMinutes = 5;
DateTime time;
if (timeStamp.IsNumber())
{
time = TimeExtensions.FromUnixTimeMilliseconds(Int64.Parse(timeStamp));
}
else
{
time = DateTime.Parse(timeStamp);
}
var timeSpan = DateTime.Now - time;
if (Math.Abs(timeSpan.TotalMinutes) > TimeSpanMinutes)
{
throw new Exception("客户端请求时间与服务端当前时间差不能超过 5 分钟");
}
这里 Math.Abs 用得很讲究:既防止"请求过期",也防止"未来时间"攻击(黑客随便写一个 2099 年的时间戳,差值就不会是负数)。
进阶建议:如果同一 timestamp 的请求还想做到"仅允许一次",可以在 Redis 存
sign:{userId}:{timestamp},10 分钟 TTL,命中就直接拒绝,达到严格防重放的效果。
4.2 签名公式:userId + timestamp + secret
拿到合法的时间戳后,根据 userId 从库里取 secret(对应 ApiUserService.GetSecretAsync),然后拼接并 MD5:
string signString = string.Join(",", userId, timeStamp, secret);
string validSign = HashCryptographer.ComputeHash(signString, true);
if (string.Equals(sign, validSign, StringComparison.OrdinalIgnoreCase))
{
return new JsonDataResult { message = "认证成功" };
}
throw new Exception("客户端签名与服务端签名不一致");
这里有三个细节值得学习:
- 拼接用固定分隔符:不用空格,不用
&,用逗号。好处是字段取值本身不会意外包含逗号(如果字段本来就是任意字符串,分隔符要换成更"罕见"的字符或上:+ URL 编码); ComputeHash(..., true):true通常表示结果转大写、MD5 32 位(和客户端保持一致才是关键);OrdinalIgnoreCase对比:客户端有时候算出来大写、有时候小写,忽略大小写更宽容。
4.3 整个校验包在 try/catch 里,统一返回格式
catch (Exception ex)
{
Logger.Error($"认证失败:{ex.Message}");
DbLogHelper.AddLog("API服务调用", "用户认证", userId, false, ex.Message, "", "");
return new JsonDataResult { success = false, message = $"认证失败:{ex.Message}" };
}
成功和失败都写业务日志(DbLogHelper.AddLog),成功写一条 INFO,失败写 userId + 原因,事后排查"调用方到底哪里签名不对"时,非常方便。
五、客户端怎么拼?给一份 C# 调用示例
服务端定义了规则,调用方要对齐。下面是一份可以直接跑的参考实现:
public static async Task<string> CallApi(string url, string userId, string secret)
{
using var http = new HttpClient();
// 1) 用 Unix 毫秒当时间戳,格式统一,避免不同地区时区问题
var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
// 2) 计算签名
var src = string.Join(",", userId, ts, secret);
var sign = HashCryptographer.ComputeHash(src, true); // 与服务端完全一致
// 3) 加头 + 发起请求
http.DefaultRequestHeaders.Add("user-id", userId);
http.DefaultRequestHeaders.Add("timestamp", ts);
http.DefaultRequestHeaders.Add("sign", sign);
var resp = await http.GetAsync(url);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadAsStringAsync();
}
客户端需要和服务端对齐的三件事:timestamp 格式、分隔符、MD5 大小写。只要这三者一致,基本不会出现"签名不一致"。
六、和"JWT 认证处理器"的关系
上一篇讲过《自定义 JWT 认证处理器》,它和今天的签名验证是两个层面的东西:
| 维度 | 自定义 JWT Handler | 接口签名验证(本文) |
|---|---|---|
| 使用场景 | 后台管理端用户登录后访问 | 第三方系统 / 开放平台 API 调用 |
| 凭证 | JWT Token(短时间、可续期) | userId + 永不发送的 secret |
| 防重放 | 依赖 Token 过期时间 | 时间戳窗口(可选 Redis 做一次性) |
| 防篡改 | Token 本身带签名(JWS) | 请求头 + secret 做 MD5 |
| 接入成本 | 登录换 Token → 带 Token | 申请密钥 → 每次请求算签名 |
在 SwitchData 里,这两套机制同时存在:后台管理端走 JWT,对外开放的接口走本文的签名验证,两条独立的"认证通道"在 HTTP 管道的不同阶段执行,互不干扰。
七、实战中的强化建议
SwitchData 现在的实现已经够用,但如果要对接更敏感的业务(付费接口、支付回调等),可以在下列点上再加强:
- 签名内容包含 body:目前公式里没有请求 Body,POST 请求 Body 仍可能被改。推荐把 Body 整体 SHA256 后再拼进
signString; - Nonce(随机串)+ Redis 去重:请求头里多加一个
nonce,服务端在 Redis 做一次性校验,做到"绝对不允许重放"; - 密钥轮换:给每个 ApiUser 配一个
expireTime,到期强制换 secret; - HTTPS 必须开启:签名防的是"篡改与重放",不是"窃听"。HTTPS 是最基本的底线。
八、总结
接口签名验证是开放 API 安全的第一道门。SwitchData 用短短三个文件就搭建了一套实用、可维护的方案:
- Attribute + TypeFilter:贴一下就能用,DI 友好;
- AuthorizationFilter 阶段拦截:远早于 Model Binding 和业务代码,失败不产生脏数据;
- Endpoint Metadata 读 AllowAnonymous:跳过登录接口等;
- timestamp + MD5(userId,timestamp,secret):兼顾防重放、防篡改、性能友好。
如果你正在写一个要对接第三方的 OpenAPI,这套实现可以直接抄过去,把 HashCryptographer 和 ApiUserService.GetSecretAsync 换成你项目里的等价物即可。