当你把内部 API 开放给第三方系统调用时,传输安全 往往是第一个要考虑的问题。仅仅给调用方一个账号密码(Basic Auth),明文走 HTTP,会立刻面对两个经典风险:

  1. 请求被篡改:中间人修改了请求参数或 body;
  2. 请求被重放:黑客抓包后原封不动再发 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("客户端签名与服务端签名不一致");

这里有三个细节值得学习:

  1. 拼接用固定分隔符:不用空格,不用 &,用逗号。好处是字段取值本身不会意外包含逗号(如果字段本来就是任意字符串,分隔符要换成更"罕见"的字符或上 : + URL 编码);
  2. ComputeHash(..., true)true 通常表示结果转大写、MD5 32 位(和客户端保持一致才是关键);
  3. 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 现在的实现已经够用,但如果要对接更敏感的业务(付费接口、支付回调等),可以在下列点上再加强:

  1. 签名内容包含 body:目前公式里没有请求 Body,POST 请求 Body 仍可能被改。推荐把 Body 整体 SHA256 后再拼进 signString
  2. Nonce(随机串)+ Redis 去重:请求头里多加一个 nonce,服务端在 Redis 做一次性校验,做到"绝对不允许重放";
  3. 密钥轮换:给每个 ApiUser 配一个 expireTime,到期强制换 secret;
  4. HTTPS 必须开启:签名防的是"篡改与重放",不是"窃听"。HTTPS 是最基本的底线。

八、总结

接口签名验证是开放 API 安全的第一道门。SwitchData 用短短三个文件就搭建了一套实用、可维护的方案:

  • Attribute + TypeFilter:贴一下就能用,DI 友好;
  • AuthorizationFilter 阶段拦截:远早于 Model Binding 和业务代码,失败不产生脏数据;
  • Endpoint Metadata 读 AllowAnonymous:跳过登录接口等;
  • timestamp + MD5(userId,timestamp,secret):兼顾防重放、防篡改、性能友好。

如果你正在写一个要对接第三方的 OpenAPI,这套实现可以直接抄过去,把 HashCryptographerApiUserService.GetSecretAsync 换成你项目里的等价物即可。