news 2026/10/19 9:49:29

Token + 数字签名:API接口安全认证与防篡改防重放完整方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Token + 数字签名:API接口安全认证与防篡改防重放完整方案

先说个我实际碰到的场景。某内部平台要把订单查询接口开放给合作方,上线第一周就出事了:有人拿到一个有效的Token,然后写脚本把请求里的订单号换掉,直接遍历了整个订单库;还有人把请求截下来反复提交,明明只应该创建一次的充值单,硬是生成了几十条。后来把认证体系重构了一遍,核心就是标题里说的这套“Token + 数字签名”组合方案。

这套方案做的事情其实不复杂:Token解决“你是谁”,数字签名解决“你传的东西有没有被人改过”,时间戳和nonce解决“这件事是不是你刚才干的那件”。三者叠加,就是一个完整的API接入认证、参数防篡改、请求防重放的闭环。适合后端开发、API平台设计、以及所有需要把自己接口开放给第三方的团队参考。下面我按从设计到落地的完整链路来拆解这套认证源码。

1. 认证体系整体设计与方案选型

1.1 为什么接口要同时用Token与数字签名

很多项目一开始只做了Token校验,请求头里带上Token,服务端验一下存在就放行。这能挡住“没登录的人”,但挡不住“登录了但被篡改的请求”。

打个比方:Token像一张门禁卡,证明你有资格进这栋楼。但你进门之后,你手里递过去的文件上写了“转账100元”还是“转账10000元”,门禁系统是不管的。数字签名就是给这份文件加一道物理封印——文件上任何一个字被改过,封条就会损坏。

具体到HTTP请求里,Token只能代表调用方的会话身份,但请求的URL参数、Body内容在传输过程中如果被中间人篡改,或者被合法调用方内部人员恶意修改,服务端只验Token是发现不了的。而数字签名要求调用方用一把只有他和服务端知道的密钥,对“本次请求的完整参数内容”做摘要计算,服务端拿到请求后按同样规则重新算一遍,两边不一致就直接拒绝。

所以这两者不是替代关系,是互补关系:Token负责认证身份,签名负责校验参数完整性。两者再加上防重放机制,才算是一个完整的API安全接入方案。

1.2 Token方案选型:缓存令牌与无状态令牌

Token本身也有不同的实现路线。我见过不少人一上来就用JWT,但JWT并不总是最合适的选择。这里我列个对比,方便你按自己的场景选:

方案优点缺点适合场景
无状态令牌(JWT等)不需要存储,天然支持分布式,令牌里能携带用户信息无法服务端主动吊销,过期前不可控,密钥一旦泄露全部可伪造微服务间认证、第三方单点登录
缓存令牌(Redis等)可主动踢人、实时失效、续期灵活、状态完全可控增加一次存储查询开销,依赖Redis等基础设施内部平台、开放API网关、需要账号管理的系统

我在这套源码里用的是服务端缓存Token方案,原因很实际:开放API给合作方之后,你一定会遇到某个合作方账号泄露、需要立刻把它的会话切断的情况。缓存Token模式下,删掉Redis里的key就生效了,JWT则做不到。另外Token续期、多端互踢这种运营需求,缓存Token实现起来都很自然。

具体的Token结构很朴素:登录成功后生成一个随机串(GUID或安全随机数),把它作为Key,用户标识作为Value,存储到Redis并设置过期时间。客户端后续请求带上这个Token,服务端一查便知是否有效。

1.3 签名算法约定与防重放设计

签名部分我用的不是简单的MD5拼接,而是HMAC-SHA256。原因很简单:MD5本质上是哈希摘要,理论上存在碰撞风险,而且没有密钥参与的MD5很容易被破解;HMAC-SHA256相当于“带密钥的哈希”,即使签名串被截获,攻击者不知道密钥也无法伪造新签名。

签名需要覆盖的信息包括:

  • appId:调用方身份标识,服务端通过它找到对应的secretKey
  • token:会话令牌
  • timestamp:客户端请求发起的Unix秒级时间戳
  • nonce:随机字符串,一次一用
  • 业务参数:本次请求的URL参数或/和Body内容
  • secretKey:共享密钥,只有合法客户端和服务端知道

签名的拼接规则必须双方完全一致,一个字符都不能差。我约定的规则是:

  1. 排除签名本身(sign参数),收集所有参与参数
  2. 过滤空值参数
  3. 参数的键名按字典序升序排列
  4. 拼接成key1=value1&key2=value2形式
  5. 在末尾追加&secretKey=你自己的密钥
  6. 用HMAC-SHA256计算摘要,结果转大写十六进制

这里有两个必须讲清楚的防重放设计。第一个是timestamp时间窗:客户端把当前时间戳放在请求里参与签名,服务端拿到后和本地时间做差,超过5分钟直接拒绝。这样即使签名正确,被截获的请求也不能无限期重放。第二个是nonce一次性:同一个随机串在过期时间内不允许出现第二次,服务端把它存到Redis里,第一次见到就放行并存储,第二次见到直接拒绝。为什么两个都要?因为时间窗只能限制“过期请求”,5分钟之内的重放还得靠nonce来拦。顺序上应该先验时间窗,再验nonce,最后验签名——签名计算最消耗CPU,把最容易拒绝的参数放在前面。

2. 源码核心:签名算法与服务端校验流水线

2.1 签名生成规则与代码实现

整个签名核心就是一个工具类,客户端和服务端共用同一套规则。我用C#示例,其他语言照思路翻译即可。

public static class SignHelper { /// <summary> /// 生成签名:参数按字典序拼接后,追加secretKey,使用HMAC-SHA256 /// </summary> public static string GenerateSign(IDictionary<string, string> parameters, string secretKey) { // 1. 排除sign自身,过滤空值 var sorted = new SortedDictionary<string, string>(StringComparer.Ordinal); foreach (var kv in parameters) { if (string.Equals(kv.Key, "sign", StringComparison.OrdinalIgnoreCase)) continue; if (string.IsNullOrEmpty(kv.Value)) continue; sorted[kv.Key] = kv.Value.Trim(); } // 2. 拼接参数,例如 a=1&b=2&secretKey=xxx var sb = new StringBuilder(); foreach (var kv in sorted) { sb.Append(kv.Key).Append('=').Append(kv.Value).Append('&'); } sb.Append("secretKey=").Append(secretKey); // 3. HMAC-SHA256,哈希密钥与拼接密钥保持一致 using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString())); return Convert.ToHexString(hash); } }

注意Convert.ToHexString是.NET 5以上才有的API,老项目得自己写一个byte数组转十六进制字符串的方法,这是很小的一个兼容性坑但遇到的人不少。

理解这段代码的关键点在于:SortedDictionary加上StringComparer.Ordinal保证了键名按二进制字典序排列,不会受到系统区域设置的影响。这是最容易出问题的细节,我在后面第4章会详细展开。

2.2 Token的颁发、存储与续期逻辑

登录接口负责颁发Token。核心逻辑是:校验账号密码之后,生成一个安全随机串作为Token,写到Redis里,设置2小时过期。

[HttpPost("api/auth/login")] public async Task<IActionResult> Login([FromBody] LoginRequest req) { var user = await _userRepo.GetByUsernameAsync(req.Username); if (user == null || !VerifyPassword(user.PasswordHash, req.Password)) return Unauthorized(new { code = 401, msg = "用户名或密码错误" }); var token = Guid.NewGuid().ToString("N"); var cache = _cache.GetDatabase(); var key = $"api:token:{token}"; await cache.StringSetAsync(key, user.Id.ToString(), TimeSpan.FromHours(2)); return Ok(new { code = 0, data = new { token, expireIn = 7200 } }); }

Token用GUID还是安全随机数,取决于你对安全强度的要求。GUID的随机性有一定规律可循,更稳妥的做法是RandomNumberGenerator.GetBytes(32)生成32字节随机数再转Base64。很多安全标准里对Token熵有要求,所以建议直接用安全随机数。

续期逻辑有两种玩法:固定过期和滑动过期。固定过期就是登录后2小时无论是否活跃都失效;滑动过期是每次请求如果发现Token剩余的TTL不足一定阈值,就刷新一下过期时间。滑动过期适合那种“只要一直在用就不会掉线”的桌面端或内部系统,固定过期更适合对安全边界要求严格的开放平台。我在源码里实现的是固定过期,因为简单可控,后续要改成滑动过期也只是一行刷新命令的问题。

2.3 服务端认证中间件完整校验流程

服务端的校验我用的是ASP.NET Core中间件。如果你用的是更老的ASP.NET Web API,可以改成DelegatingHandler或ActionFilter,校验逻辑完全一样。这里给出完整流程。

public class ApiAuthMiddleware { private readonly RequestDelegate _next; public ApiAuthMiddleware(RequestDelegate next) => _next = next; public async Task InvokeAsync(HttpContext context) { if (IsWhitelist(context.Request.Path.Value)) { await _next(context); return; } // 认证参数统一放在Header里 var headers = context.Request.Headers; if (!headers.ContainsKey("X-AppId") || !headers.ContainsKey("X-Token") || !headers.ContainsKey("X-Timestamp") || !headers.ContainsKey("X-Nonce") || !headers.ContainsKey("X-Sign")) { await Reject(context, "缺少认证参数"); return; } var appId = headers["X-AppId"].ToString(); var token = headers["X-Token"].ToString(); var timestamp = headers["X-Timestamp"].ToString(); var nonce = headers["X-Nonce"].ToString(); var clientSign = headers["X-Sign"].ToString(); var secretKey = GetSecretKey(appId); if (string.IsNullOrEmpty(secretKey)) { await Reject(context, "无效的AppId"); return; } // 1. 时间窗防重放:前后5分钟 if (!long.TryParse(timestamp, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) { await Reject(context, "请求已过期"); return; } // 2. Token有效性校验 var db = context.RequestServices.GetRequiredService<IConnectionMultiplexer>().GetDatabase(); var tokenValue = await db.StringGetAsync($"api:token:{token}"); if (tokenValue.IsNullOrEmpty) { await Reject(context, "Token无效或已过期"); return; } // 3. nonce一次性校验,必须用原子操作 var nonceKey = $"api:nonce:{appId}:{nonce}"; var first = await db.StringSetAsync(nonceKey, "1", TimeSpan.FromMinutes(10), When.NotExists); if (!first) { await Reject(context, "重复请求"); return; } // 4. 重建签名并比对 var parameters = await CollectParametersAsync(context.Request); parameters["X-AppId"] = appId; parameters["X-Token"] = token; parameters["X-Timestamp"] = timestamp; parameters["X-Nonce"] = nonce; var serverSign = SignHelper.GenerateSign(parameters, secretKey); if (!string.Equals(serverSign, clientSign, StringComparison.OrdinalIgnoreCase)) { await Reject(context, "签名不正确"); return; } context.Items["UserId"] = tokenValue.ToString(); await _next(context); } }

这里校验顺序是精心安排的:先查Header是否齐全,再验时间窗,再验Token,再用nonce做防重放,最后才验签名。原因很直接——签名计算要把Query和Body都收集起来再拼接再哈希,是最贵的操作;而时间窗和nonce查询是廉价的。把便宜且高概率拦截的检查放在前面,能快速挡住大量非法请求。

CollectParametersAsync的实现有一个关键点:Body只能读一次。要同时支持签名校验和下游控制器读Body,必须做缓冲并重置流位置。

private static async Task<Dictionary<string, string>> CollectParametersAsync(HttpRequest request) { var dict = new Dictionary<string, string>(); foreach (var q in request.Query) dict[q.Key] = q.Value.ToString(); if (request.Method != "GET" && request.ContentLength > 0) { request.EnableBuffering(); request.Body.Position = 0; using var reader = new StreamReader(request.Body, Encoding.UTF8, leaveOpen: true); var body = await reader.ReadToEndAsync(); request.Body.Position = 0; if (!string.IsNullOrWhiteSpace(body)) { dict["body"] = body; } } return dict; }

这里我约定:当请求Body是JSON时,不把JSON拆成字段,而是把原始字符串放进一个名为body的伪参数参与签名。这样做的好处是避免JSON字段顺序差异导致签名不一致——因为用的是原字符串,客户端序列化出来什么样,服务端拿到的就是什么样,只要双方使用同一个原始字符串计算签名即可。

3. 实操落地:从客户端封装到接口联调

3.1 客户端签名工具封装

客户端部分我用HttpClient的DelegatingHandler来封装,这样业务代码完全不用感知签名过程,只要在创建HttpClient时注入这个Handler。

public class ApiAuthHandler : DelegatingHandler { private readonly ApiAuthOptions _options; public ApiAuthHandler(ApiAuthOptions options) { _options = options; InnerHandler = new HttpClientHandler(); } protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var parameters = new Dictionary<string, string>(); if (!string.IsNullOrEmpty(_options.Token)) request.Headers.TryAddWithoutValidation("X-Token", _options.Token); // 收集URL上的Query参数 var uri = request.RequestUri; if (!string.IsNullOrEmpty(uri.Query)) { foreach (var kv in Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query)) parameters[kv.Key] = kv.Value.ToString(); } // 收集Body原始内容 if (request.Content != null) { var body = await request.Content.ReadAsStringAsync(cancellationToken); if (!string.IsNullOrWhiteSpace(body)) parameters["body"] = body; } var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); var nonce = Guid.NewGuid().ToString("N"); request.Headers.TryAddWithoutValidation("X-AppId", _options.AppId); request.Headers.TryAddWithoutValidation("X-Timestamp", timestamp); request.Headers.TryAddWithoutValidation("X-Nonce", nonce); parameters["X-AppId"] = _options.AppId; parameters["X-Timestamp"] = timestamp; parameters["X-Nonce"] = nonce; var sign = SignHelper.GenerateSign(parameters, _options.SecretKey); request.Headers.TryAddWithoutValidation("X-Sign", sign); return await base.SendAsync(request, cancellationToken); } }

这个封装有个隐蔽的坑:ReadAsStringAsync读了Body之后,原来的Content流会被消耗。HttpClient请求体一般是一次性的,所以这里读取本身就是为了签名,读完之后请求照样能发出去,因为HttpClient会从缓冲区重新序列化。但要注意,Body读取的字符串必须和真正发送时的字节内容一致,UTF-8编码空格、缩进这些都会影响签名结果。如果客户端构造HttpContent时用的是StringContent,那么这里读到的字符串就等同于发给服务端的原始字符串,两边能对上。

3.2 服务端接入配置与白名单设置

服务端配置我分成两部分:一部分是认证中间件本身的选项目录,另一部分是调用方密钥映射。

配置示例:

{ "ApiAuth": { "TimeWindowSeconds": 300, "Clients": { "10001": "你分配的第一个调用方密钥", "10002": "你分配的第二个调用方密钥" } } }

GetSecretKey(appId)就是从ApiAuth:Clients这个字典里按appId取出对应的secretKey。每个调用方一个独立的密钥,而不是全局一把密钥。这样某个合作方密钥泄露,只需要撤销对应的appId,其他合作方完全不受影响。

白名单路径一般是登录接口、健康检查接口这种不需要认证的入口。中间件里维护一个路径列表,命中就直接放行。这里有个经验:白名单匹配最好使用严格路径或前缀匹配,不要用contains,否则很容易出现某个业务接口路径里恰好含有关键词而被错误放行的情况。

3.3 可复现的联调Demo

为了让这套方案能快速验证,我写了一个最简单的调用流程。登录拿到Token,然后请求订单列表接口。

var loginResp = await client.PostAsJsonAsync("http://localhost:5000/api/auth/login", new { username = "demo", password = "123456" }); var loginResult = await loginResp.Content.ReadFromJsonAsync<LoginResult>(); var token = loginResult.Data.Token; var authedClient = new HttpClient(new ApiAuthHandler(new ApiAuthOptions { AppId = "10001", SecretKey = "test_secret_key", Token = token })); var json = await authedClient.GetStringAsync( "http://localhost:5000/api/orders?page=1&size=20");

第一次自己手工验证签名时,建议把客户端生成的签名串和服务端重算的签名串都打出来对比。打印的时候不要打密钥,只打拼接串和最终签名。你会很快发现很多“签名不匹配”的问题根本不是算法问题,而是某个空值处理不一致。

4. 生产环境踩坑记录与排查速查表

4.1 签名不匹配的五个高频原因

我在联调和维护这套系统的过程中,见过的签名不匹配绝大多数不是算法问题,而是拼接规则的问题。这里列一个速查表。

现象最常见原因排查方式
只有个别请求签名失败某个请求参数为空或为NULL,一方过滤另一方没过滤统一约定空值不参与签名
客户端用中文系统没问题,英文系统失败排序时用了默认字符串比较改用StringComparer.Ordinal
数值字段失败,比如100.00变100decimal/double的ToString格式因环境不同产生差异参数统一用字符串传输
带Body的请求全部失败Body读取与序列化格式不一致用原始body字符串参与签名
偶尔成功偶尔失败参数顺序或URL编码差异客户端服务端同时打印拼接串对比

这里我要特别强调StringComparer.Ordinal这个细节。很多人排序时直接用了字符串默认排序,这在中文操作系统的区域设置下,比较规则和英文系统不一样,就会导致同样的参数在客户端和服务端排序结果不同,签名算出来必然对不上。用Ordinal比较器本质上是按字符的Unicode码点排序,在任何系统上结果一致。

4.2 非ce防重放并发漏洞与Redis原子操作

nonce防重放这里埋着一个特别容易忽略的并发漏洞。如果你按“先Get检查是否存在,不存在再Set”的思路实现:

var exists = await db.StringGetAsync(nonceKey); if (!exists.IsNullOrEmpty) return Reject(); await db.StringSetAsync(nonceKey, "1", ...);

那么两个携带相同nonce的请求同时到达时,可能同时查到“不存在”,然后都执行了Set,两个请求都被放行了。正确做法是用Redis的SETNX单命令保证原子性:

var first = await db.StringSetAsync(nonceKey, "1", TimeSpan.FromMinutes(10), When.NotExists); if (!first) { await Reject(context, "重复请求"); return; }

When.NotExists只有在key不存在时才会写入并返回true,并发情况下只有一个请求能成功,这才是可靠的防重放。这个坑在生产环境不一定会立刻暴露,但一旦并发量上来,重放请求就能钻空子。

4.3 文件上传等特殊场景的处理约定

文件上传是签名方案里最棘手的一类场景。二进制内容不能直接拼进签名串,因为文件内容可能很大,而且客户端和服务端拿到的文件字节在传输过程中可能被转换。我实践中常用两种约定:

第一种是“先传文件,再提业务”。客户端先把文件上传到独立接口获得一个fileId,之后业务请求里用fileId作为参数参与签名。服务端处理业务时再用fileId去拿真正的文件。这样签名走的全是普通字符串,干净可靠。

第二种是“签名只覆盖表单字段”。文件本身不参与签名,但表单里的原始文件名、大小、SHA256值参与签名,服务端收到文件后校验这几个字段是否一致。这种方式适合小文件,仍然能防止表单字段被篡改。

无论哪种方式,都要把二进制内容排除在签名参与范围之外,并在接口文档里明确标注,否则调用方只能对着“签名不匹配”干瞪眼。

4.4 密钥轮换、多环境配置与日志脱敏

密钥管理看起来是个老生常谈的话题,但在实际落地时经常被忽略。我见过有团队直接把secretKey硬编码在代码里,然后因为一个Git仓库泄露,所有调用方的密钥全部暴露。密钥不应该出现在代码仓库里,应该放在环境变量或配置中心,敏感程度要像数据库密码一样对待。

密钥轮换建议采用“新旧双密钥过渡”的方式:客户端和服务端各保留当前密钥和历史密钥,签名校验时先用当前密钥运算,失败再试历史密钥,等所有调用方都切换完成后,再下线旧密钥。这样轮换过程中不会出现服务中断。我实际执行下来的节奏是每个月轮换一次主要外部合作方密钥。

日志脱敏是另一个容易漏的环节。中间件在某次排查问题时,如果顺手把完整的拼接串打到了日志里,那等于把密钥和签名规则一起泄露了。打印拼接串时可以打参数名列表和长度,不要打真实值;final签名可以打出来对比,但secretKey绝不能出现在日志里。我建议在日志输出层面对包含secretKey、sign字段的参数统一做mask处理。

最后再分享一个我实际维护这套方案时的小体会:签名认证方案折腾到最后,真正难的不是算法,而是“规则的一致性”。客户端和服务端必须共享同一份参数拼接规则、同一套空值处理策略、同一个键名比较器,稍微有一点偏差就是无穷无尽的报错。所以我后来把签名规则写成了单独的一页文档,附上两端可以复制的完整示例代码,凡是新接入的团队,先跑通文档里的示例,再做业务接入。这比任何人拍脑袋解释都省事。这套“Token + 数字签名”方案目前还在我维护的项目里稳定运行,如果你的API也面临被篡改、被刷量、被重放的问题,直接照这套流程落地,可以少走不少弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 9:48:28

网络路由实验全解析:从静态路由到RIP/OSPF配置与排错

网络路由实验&#xff08;计网实验prj11&#xff09;——这大概是计网系列实验里最让人又爱又恨的一个。爱的是它把你前面学过的IP地址、子网划分、数据链路层封装全部串了起来&#xff0c;做完之后你对“一个数据包到底怎么从一个网段跑到另一个网段”会有非常直观的认知&…

作者头像 李华
网站建设 2026/10/19 9:47:18

生产环境Linux故障排查:定位、命令、场景与复盘全解析

半夜两点四十&#xff0c;值班手机被报警震醒这种事&#xff0c;干运维的迟早都会碰上。屏幕上监控曲线像一把直插天花板的尖刀&#xff0c;生产机器一个小时前还能正常响应&#xff0c;现在 SSH 连上去敲个命令都要卡三秒。这种时候最考验人的不是命令背得多熟&#xff0c;而是…

作者头像 李华
网站建设 2026/10/19 9:46:58

open-code-review:面向工程落地的本地化AI代码审查CLI工具

1. 这不是又一个“AI代码审查”玩具&#xff1a;open-code-review 的真实定位与生存逻辑open-code-review 这个名字乍看平平无奇&#xff0c;甚至有点“开源即正义”的朴素感。但如果你最近刷过 GitHub Trending、Hacker News 或者国内的 V2EX、知乎技术区&#xff0c;会发现它…

作者头像 李华
网站建设 2026/10/19 9:46:57

Altera FPGA上Nios软核通过Avalon总线驱动I2C IP的调试指南

简介&#xff1a;这套资料面向ALTERA FPGA嵌入式开发者&#xff0c;提供了I2C主控制器IP的完整VHDL实现&#xff0c;并展示如何通过Avalon接口挂接到NIOS II软核处理器&#xff0c;适用于需要与传感器、存储器等外设通信的项目。I2C协议只需两根信号线即可实现全双工通信&#…

作者头像 李华
网站建设 2026/10/19 9:46:12

基于代理的演化搜索框架AgentLTV:破解LTV预测与用户分层难题

聊一个做用户增长的同学这两年都绕不开的问题&#xff1a;LTV预测。LTV&#xff0c;Lifetime Value&#xff0c;说白了就是估算一个用户从进来那天到他流失为止&#xff0c;到底能贡献多少价值。听着简单&#xff0c;可真要亲手训练一个能上线的模型&#xff0c;你会发现它比一…

作者头像 李华
网站建设 2026/10/19 9:45:05

作物模型参数优化与光谱检测:WOFOST干物质分配实战指南

1. 从田间到模型&#xff1a;为什么作物模型参数优化是个绕不开的坎搞作物模型的人都有一个共识&#xff1a;模型框架本身其实大同小异&#xff0c;真正决定模拟精度的&#xff0c;是参数。WOFOST、DSSAT、APSIM这些主流模型&#xff0c;结构上都是把光合、呼吸、蒸腾、分配、衰…

作者头像 李华