ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前后端接口签名验证实战:HMAC-SHA256防篡改与防重放

前后端接口签名验证实战:HMAC-SHA256防篡改与防重放 1. 为什么前后端之间需要一套签名验证先说个场景大家就明白我在聊什么了。你辛辛苦苦写了一个PHP接口返回用户余额、订单状态、会员等级这类数据前端Ajax请求过来后端查询数据库返回JSON。上线跑了一个月某天突然发现有人写了个脚本批量调用你的接口伪造参数薅羊毛或者把别人的订单号拿来查数据。这时候你才意识到光有接口还不够还得让接口认得谁在调用、参数是否被改过。签名验证Signature Verification就是干这个事的。它的本质很简单在前后端之间约定一套暗号规则前端发送请求时把请求参数 时间戳 随机数 一个只有前后端知道的密钥AppSecret按照固定规则拼成一个字符串再用哈希算法算出一串固定长度的签名字符串一起发给后端。后端收到后用同样的规则和同样的密钥重新算一遍如果算出来的签名和前端传过来的一致说明参数没被改动请求来自可信的调用方不一致直接拒绝。这套机制在实际开发中几乎是标配。不管是小程序、App、H5还是服务端之间的接口调用签名验证都是最基础的一道防线。它解决的核心问题是三件一是参数有没有被中间人篡改二是请求是不是伪造的三是同一个请求能不能被反复重放。理解了这三个目标后面所有设计细节都是围绕它们展开的。很多人会问前端代码是公开的签名算法和密钥都在前端代码里那不就等于裸奔了吗这个问题问得非常好也确实是指尖的痛点。说实话前端签名验证从来不是为了防住一个拿着源码逐行分析、铁了心要逆向你接口的专业黑客——那需要更复杂的防护手段比如加固、混淆、风控。前端签名验证防的是那些顺手牵羊的脚本小子是那些不费吹灰之力就能批量刷你接口的自动化工具。它能大幅提高调用接口的门槛让大多数人望而却步这就已经达到目的了。所以这篇文章我会从一个实际项目的角度把前后端签名验证的完整思路、设计细节、PHP后端实现、前端JS实现以及我踩过的坑全部拆开讲清楚。项目用的技术栈是后端PHP 7.4前端原生JavaScript带一个fetch封装示例没有引入复杂的框架方便大家直接迁移到自己的项目里。2. 签名机制的整体设计与方案选型2.1 签名到底要解决什么问题在设计签名机制之前先把问题定义清楚。一套接口如果没有签名验证会遇到哪些攻击场景参数篡改用户把请求里的商品价格从100改成1把订单金额改了后端不校验就入库损失直接可见。身份伪造攻击者拿不到你的登录态但接口只校验登录态是否有效不校验请求是否来自合法客户端那攻击者只要盗用一个有效的登录凭证就能模拟客户端发起请求。重放攻击同一个合法请求被拦截下来反复发送。比如用户提交了一个确认收货的请求攻击者把请求包保存下来隔几天再发一遍系统可能会重复处理。批量抓取爬虫模拟正常前端请求把接口数据批量抓走你辛苦整理的数据被白嫖。签名验证不能解决所有安全问题比如登录态被盗跟签名就没关系那是会话管理的事但它能很好地解决参数篡改和重放这两个核心问题同时给身份伪造和批量抓取设置门槛。2.2 为什么选择HASH签名而不是其他方案签名算法有很多选择常见的有MD5、SHA1、SHA256、HMAC-SHA256还有更复杂的RSA非对称签名。我最终选了HMAC-SHA256原因是它在安全性和实现成本之间最平衡。纯MD5拼接虽然实现简单但MD5本身已经不安全而且拼接密钥算MD5这种方式在学术界有长度扩展攻击的隐患RSA非对称签名安全性最高但需要管理证书、密钥对前后端各持一份对前端来说私钥没法安全存放反而画蛇添足。HMAC-SHA256的全称是Hash-based Message Authentication Code基于哈希的消息认证码。它的核心操作是把密钥Key和消息Message一起参与哈希运算输出固定长度的摘要。跟把密钥拼在消息后面一起算MD5这种朴素做法相比HMAC有标准的填充和异或流程密钥参与得更充分抗碰撞和长度扩展的能力更强。而且PHP内置的hash_hmac函数直接支持一行代码就能算出来前端用CryptoJS也可以一行算出几乎没有学习成本。2.3 签名参与元素的选定逻辑签名参与的元素我建议至少包括这几个时间戳timestamp10位的Unix时间戳用来防御重放攻击。后端收到请求后判断当前时间和请求携带的时间戳之差是否在允许范围内比如5分钟超时就拒绝。随机数nonce每次请求生成一个唯一字符串配合时间戳一起用。为什么有了时间戳还需要随机数因为同一个时间戳内如果请求被重放时间戳校验是可以通过的因为时间差在允许范围内这时候就需要记录这个随机数是否已经被用过用过就拒绝。随机数让每个请求都独一无二。参数内容params参与签名的参数需要按规则排序、拼接保证任何参数改动都会导致签名变化。密钥secret前后端约定的AppSecret不进请求参数只存在于签名算法中。这四个元素缺一不可。时间戳管时效随机数管唯一性参数管完整性密钥管可信性。少了任何一个签名验证的防护能力都会打折。2.4 密钥管理和前端暴露的边界密钥是签名机制里最敏感的东西在设计之初就要想清楚前端到底能拿到什么。我的做法是前端签名用的密钥和后端内部加密用的密钥完全分开。前端用的那个密钥即使被扒出来了也只能用来伪造签名请求接口影响面控制在这一层而后端数据库加密、支付回调验签这些更敏感的密钥绝不出现在前端代码里。另外正式环境的密钥不要直接硬编码在前端JS代码里建议通过后端接口动态下发客户端启动时拉取一次缓存到内存中。这样即使代码被查看也拿不到线上环境的密钥。3. PHP后端的签名生成与校验实现3.1 签名规则的定义先把签名规则定下来前后端必须完全一致这是整套机制的基石。我常用的规则如下将所有请求参数不包括sign本身、文件类型参数按照参数名的ASCII码升序排序。将排序后的参数按照参数名参数值的格式用连接成一个字符串。例如amount100order_no20241201001timestamp1733039600。将密钥拼接进待签名字符串。我习惯在字符串末尾拼接key你的AppSecret这样简单清晰也方便前后端统一下规则。对待签名串使用HMAC-SHA256计算摘要然后将结果转换成小写十六进制字符串作为最终的签名。这里有一个很关键的细节参数值不要做URL编码。很多人习惯把参数先URL编码再拼接签名但这会给前端和后端带来一堆编码不一致的坑。我们的做法是签名的拼接使用原始参数值即不编码的字符串而真正发送请求时正常进行URL编码。前后端都遵守这个约定签名就能对上。3.2 PHP服务端的签名校验完整代码下面给出一个我在生产环境中使用的PHP签名校验类代码做了精简保留了核心逻辑。?php class SignValidate { // 允许的时间偏差秒 private const TIME_DEVIATION 300; // 前端签名使用的密钥实际生产环境建议动态获取 private const APP_SECRET your-app-secret-key; /** * 校验前端请求的签名 * * param array $params 请求参数包含 timestamp、nonce、sign * return bool|string 通过返回true失败返回错误信息 */ public static function check(array $params) { // 1. 基础参数检查 if (empty($params[timestamp]) || empty($params[nonce]) || empty($params[sign])) { return 缺少签名参数; } // 2. 时间戳校验防重放 $timestamp (int)$params[timestamp]; if (abs(time() - $timestamp) self::TIME_DEVIATION) { return 请求已过期; } // 3. 随机数防重放 if (self::nonceExists($params[nonce])) { return 重复请求; } // 4. 取出签名并从参数数组中移除 $sign $params[sign]; unset($params[sign]); // 5. 按照签名规则重新计算 $serverSign self::makeSign($params, self::APP_SECRET); // 6. 比较签名是否一致 if (hash_equals($serverSign, $sign)) { // 验签通过记录nonce防重放 self::saveNonce($params[nonce], $timestamp); return true; } return 签名校验失败; } /** * 生成签名 * * param array $params 参与签名的参数 * param string $secret 密钥 * return string 小写十六进制签名字符串 */ public static function makeSign(array $params, string $secret): string { // 1. 过滤掉空值和sign本身 $params array_filter($params, function ($value) { return $value ! $value ! null; }); // 2. 按键名ASCII码升序排序 ksort($params); // 3. 拼接成字符串 $str ; foreach ($params as $key $value) { $str . $key . . $value . ; } // 去掉末尾多余的 $str rtrim($str, ); // 4. 拼接密钥 $str . key . $secret; // 5. HMAC-SHA256签名 return hash_hmac(sha256, $str, $secret); } /** * 检查随机数是否已被使用这里用Redis实现 */ private static function nonceExists(string $nonce): bool { $redis self::getRedis(); $key sign:nonce: . $nonce; return $redis-exists($key) 1; } /** * 记录随机数有效期与时间偏差保持一致 */ private static function saveNonce(string $nonce, int $timestamp): void { $redis self::getRedis(); $key sign:nonce: . $nonce; $redis-setex($key, self::TIME_DEVIATION 60, 1); } private static function getRedis() { // 这里根据项目本身的Redis连接方式实现 // 例如 Laravel: Redis::connection()-client() // 或者原生: $redis new \Redis(); $redis-connect(127.0.0.1, 6379); throw new \Exception(请根据项目实际接入Redis); } }这段代码里有一个细节值得特别说明。第6步我用hash_equals而不是或来比较签名原因在于hash_equals是PHP 5.6.0之后提供的字符串比较函数它能有效地防止时序攻击。简单解释一下普通的字符串比较在遇到第一个不相同的字符时就会返回结果所以比较时间跟两个字符串前面有多少相同字符是相关的。理论上攻击者可以通过反复提交精心构造的签名观察响应时间的细微差别一点点推断出正确的签名。hash_equals会在内部做固定时间的比较无论两个字符串在哪个位置不同消耗的时间基本一致这就切断了通过时间侧信道推断签名的途径。细节虽小但在涉及资金、用户数据的场景里值得认真对待。3.3 在业务代码中如何接入签名校验签名校验类写好之后接入业务代码非常简单。以原生PHP为例入口文件这样写?php // index.php require_once SignValidate.php; // 获取请求参数GET和POST合并也可以包含请求头里的参数 $params array_merge($_GET, $_POST); // 广播式接入先验证签名通过再走业务逻辑 $result SignValidate::check($params); if ($result ! true) { header(Content-Type: application/json); echo json_encode([ code 40001, msg $result, data null ]); exit; } // 签名验证通过继续处理业务逻辑 // 例如订单查询、用户信息获取等 $orderNo $params[order_no] ?? ; echo json_encode([ code 0, msg success, data [order_no $orderNo] ]);如果是Laravel框架建议做成一个中间件放在路由组里统一调用比在每个控制器里手动调用要优雅得多。中间件的核心逻辑不变只是换了个入口。3.4 服务端签名校验的补充细节关于签名密钥的前后端一致性签名规则里前端和后端必须使用完全相同的排序规则、拼接格式和哈希算法。这个一致性问题最典型的翻车点就是ksort排序。PHP默认的ksort是升序排列但遇到中文字符串排序结果可能跟JavaScript里的localeCompare排序不一致。我的建议是签名参数尽量全部使用英文字母开头的参数名避开非ASCII字符的排序差异。如果实在避不开中文参数名最稳妥的办法是前后端都使用sort()或ksort()按字节序排序同时约定参数编码统一UTF-8。关于参数值的类型问题PHP里$_GET[amount]取出来的值是字符串100而前端JS里{amount: 100}是数字100拼接签名时amount100是一样的但如果是布尔值前端{status: true}拼出来是statustruePHP这边$_GET[status]取到的是字符串 1拼出来是status1签名就对不上了。解决方案是前端在拼接签名前把所有参数值都转成字符串类型布尔值转成true/false或1/0前后端保持一致。最稳妥的做法是前端发送请求前统一做一层参数序列化年龄、金额、状态这些字段全部String(value)。关于密钥下发生产环境不要在前端代码里写死密钥尤其是小程序这种代码包可以被直接解包的场景。建议的做法是前端启动时调一个单独的getConfig接口后端校验基础身份比如设备指纹、IP、基础Header后下发一个临时密钥前端缓存到内存变量里请求业务接口时用它来签名。密钥可以设计成定期轮换比如24小时有效过期后前端需要重新获取。这样即使某个密钥泄露影响范围也是有限的过期就自动失效。4. 前端签名的生成与请求封装4.1 前端签名核心代码前端的签名验证逻辑必须跟后端完全一致参数升序排列、拼接待签名字符串、拼接密钥、HMAC-SHA256计算。我这里使用原生JavaScript实现不依赖框架。// 签名工具类 const SignUtil { // 生成签名 generateSign(params, secret) { // 1. 过滤空值 const filteredParams {}; for (const key in params) { if (params.hasOwnProperty(key)) { const value params[key]; if (value ! value ! null value ! undefined) { // 2. 将参数值统一转为字符串避免数字和字符串不一致 filteredParams[key] String(value); } } } // 3. 按键名ASCII码升序排序 const sortedKeys Object.keys(filteredParams).sort(); // 4. 拼接成字符串 let str ; for (const key of sortedKeys) { str ${key}${filteredParams[key]}; } str str.slice(0, -1); // 去掉末尾 // 5. 拼接密钥 str key${secret}; // 6. HMAC-SHA256计算 return this.hmacSha256(str, secret); }, // HMAC-SHA256实现 hmacSha256(message, secret) { // 使用 Web Crypto API 或引入 CryptoJS // 这里给出使用 CryptoJS 的写法引入后即用 return CryptoJS.HmacSHA256(message, secret).toString(CryptoJS.enc.Hex); } };CryptoJS是前端常用的加密库通过CDN引入即可script srchttps://cdn.jsdelivr.net/npm/crypto-js4.1.1/crypto-js.js/script如果项目本身用了Webpack/Vite可以通过npm安装npm install crypto-js然后import CryptoJS from crypto-js就行。值得多说一句Web Crypto API 是浏览器原生提供的加密接口性能更好且不需要引入外部库但crypto.subtle必须要在安全上下文HTTPS或localhost下才能使用如果项目是HTTP环境就会踩坑。我之前的经验是小型项目直接用CryptoJS省心大型项目可以封装一个工具类优先使用Web Crypto API不支持时降级到CryptoJS。4.2 请求拦截器里如何自动加签名在真实项目中不可能每个请求都手动去生成签名。正确做法是封装一个统一的请求工具在拦截器里自动附加时间戳、随机数、签名。下面以fetch封装为例// request.js class HttpClient { constructor(baseURL, secret) { this.baseURL baseURL; this.secret secret; // 临时密钥也可以通过getConfig接口动态获取 this.tempSecret secret; } // 刷新临时密钥 async refreshSecret() { const res await fetch(${this.baseURL}/api/config/getSecret); const data await res.json(); if (data.code 0) { this.tempSecret data.secret; } } // 统一请求方法 async request(url, options {}) { // 1. 获取公共参数 const timestamp Math.floor(Date.now() / 1000); const nonce this.generateNonce(); // 2. 合并业务参数和公共参数 const params { ...options.data, timestamp, nonce }; // 3. 生成签名 const sign SignUtil.generateSign(params, this.tempSecret); // 4. 发起请求sign放在请求头中 const finalOptions { ...options, headers: { Content-Type: application/json, X-Sign: sign, X-Timestamp: timestamp, X-Nonce: nonce, ...options.headers } }; // 5. GET请求拼接queryPOST请求放body let url ${this.baseURL}${url}; if (finalOptions.method GET || !finalOptions.method) { const queryStr Object.keys(params).map(key ${encodeURIComponent(key)}${encodeURIComponent(params[key])} ).join(); url ?${queryStr}; finalOptions.body null; } else { finalOptions.body JSON.stringify(options.data || {}); } const res await fetch(url, finalOptions); const data await res.json(); // 6. 签名过期统一处理 if (data.code 40001 data.msg 请求已过期) { // 刷新密钥后重试一次 await this.refreshSecret(); return this.request(url, { ...options, retry: true }); } return data; } // 生成随机数 generateNonce() { return ${Date.now()}_${Math.random().toString(36).substr(2, 10)}; } }这里有个设计上的取舍。我把sign、timestamp、nonce放到了请求头里X-Sign、X-Timestamp、X-Nonce而不是放在URL参数或POST body里。这样做的好处是业务参数和签名参数分离后端统一从Headers里取签名相关的数据从Request里取业务数据逻辑更清晰。同时URL里不会因为多了sign、nonce这些参数而变得冗长日志里也更方便排查。有些团队习惯把所有参数包括sign一起放在URL里这种做法也可以但需要注意日志脱敏。如果你用了Nginx访问日志URL里的sign参数会原样记录在日志文件里如果日志文件不小心被泄露攻击者就能拿这些签名做分析。放在请求头里Nginx默认不会记录Header信息风险小很多。4.3 前端参数序列化时容易踩的坑前端生成签名时参数序列化是最容易出问题的地方。我总结出三个高发坑第一个坑是数组和对象的序列化不一致。比如某个接口需要传一个标签列表tagIds: [1, 2, 3]前端拼签名时如果直接String(tagIds)得到的是1,2,3但后端PHP收到的是tagIds[]1tagIds[]2tagIds[]3这样一组参数签名肯定对不上。我的解决办法是对于数组参数前端在生成签名前先JSON.stringify数组把它当作字符串参与签名后端在做签名校验前也先把这个字段的值取出来此时它是一个数组再用同一个json_encode方法转成字符串参与签名计算。两边约定数组字段先JSON序列化再参与签名。第二个坑是文件上传。文件内容二进制数据不可能直接参与签名一般的做法是文件不上传时所有普通参数参与签名有文件上传时将文件的文件名、大小、类型这些元信息参与签名文件二进制内容排除在外。后端校验时同样只校验参数和文件元信息不校验文件内容。这样设计的原因很简单文件内容校验应该由上传后的文件校验MD5、大小限制等来保证签名机制管不到也不需要管到那个层面。第三个坑是嵌套对象。比如参数里有一个userInfo: { name: 张三, age: 18 }前端直接拼对象会变成[object Object]这显然是错的。我的约定是嵌套对象统一用JSON.stringify序列化成JSON字符串作为签名字符串的值与后端PHPjson_encode对同一个数组的输出保持一致。但这里又要注意JSON.stringify和json_encode对中文的编码方式不同PHP的json_encode默认会把中文转成\u开头的Unicode转义序列而JS的JSON.stringify不会。解决办法是在PHP侧调用json_encode($value, JSON_UNESCAPED_UNICODE)强制不转义中文这样两边输出就一致了。这个坑我踩过一次排查了整整一天最后发现就是中文编码不一致。5. 进阶场景敏感参数加密传输5.1 签名和加密的边界签名解决了参数没有被篡改的问题但它不解决参数可以被别人看到的问题。比如前端登录接口用户输入的密码在请求体里是明文传输的虽然签名能保证密码在传输过程中不被篡改但抓包的人直接就能看到密码内容。如果项目对敏感字段有保密要求就需要在签名基础上叠加一层加密。签名验证和加密传输是两个维度的东西签名是防篡改完整性加密是防泄露机密性。两者可以结合使用常规做法是AES对称加密 RSA非对称加密传输AES密钥。但在前后端场景里RSA的公钥放在前端是公开的前端用公钥加密一个随机的AES密钥发送给后端后端用私钥解密得到AES密钥之后双方用这个AES密钥做对称加密通信。这个流程有点重如果是内部管理系统一般只对敏感字段做AES加密就够了。5.2 简单有效的AES参数加密方案这里分享一个轻量级方案前端生成一个16位的随机AES密钥用RSA公钥加密后连同密文一起发送后端先用RSA私钥解密拿到AES密钥再用AES密钥解密密文。每一次请求的AES密钥都是随机生成的即使某次通信被抓包得到的AES密钥也无法用于其他请求。前端核心代码async function encryptRequest(data, serverPublicKey) { // 1. 生成16位随机AES密钥 const aesKey CryptoJS.lib.WordArray.random(16).toString(); // 2. AES加密业务参数 const encryptedData CryptoJS.AES.encrypt(JSON.stringify(data), aesKey).toString(); // 3. RSA公钥加密AES密钥 const encryptedKey await encryptRSA(aesKey, serverPublicKey); // 4. 组合请求 return { encrypted_key: encryptedKey, encrypted_data: encryptedData, timestamp: Math.floor(Date.now() / 1000), nonce: generateNonce() }; }PHP后端解密核心代码public function decryptRequest(array $params) { $privateKey file_get_contents(/path/to/rsa_private_key.pem); // 1. 用RSA私钥解密AES密钥 $aesKey ; openssl_private_decrypt( base64_decode($params[encrypted_key]), $aesKey, $privateKey, OPENSSL_PKCS1_PADDING ); // 2. 用AES密钥解密密文 $decrypted openssl_decrypt( $params[encrypted_data], AES-128-CBC, $aesKey, 0, $iv // 16字节的IV可以约定固定值或放在参数里一起加密 ); // 3. 返回明文参数 return json_decode($decrypted, true); }这套方案在前后端之间增加了一层加密保护敏感信息在传输过程中不再以明文形式暴露。但要注意加密不是万能的它解决的是传输层的泄露问题如果前端代码本身被恶意修改攻击者照样可以调用加密函数对任意数据加密所以加密挡不住业务逻辑的攻击只挡得住只看包不发包的普通用户。5.3 混合方案先签名后加密还是先加密后签名这个顺序问题在多人协作中很容易产生分歧。我的建议是先加密后签名。也就是先用AES加密业务数据再把密文、密钥密文、时间戳、随机数一起参与签名后端收到后先验签再解密。为什么先加密后签名因为签名是对最终发送内容的完整性保障如果先签名再加密密文本身没有被签名保护攻击者虽然改不了签名内容但他可以直接替换整个加密包密文和签名一起换后端验签通过但解出来的内容已经被调包这就绕过了完整性校验。先加密再签名所有密文都参与签名计算任何改动都会导致验签失败逻辑上更严谨。6. 签名验证中的常见问题和排查技巧6.1 典型问题速查表我在实际项目中把常见的签名验证问题整理成了一张速查表遇到问题时直接对照着查排查效率能提高不少。现象可能原因排查方法前端请求一直报签名校验失败参数排序不一致后端开启调试模式把待签名字符串打印出来和前端拼接的结果对比刚发出去的请求报请求已过期服务器时间不同步检查PHP服务器时间或前端用的是本地时间而服务器时间差太大某个接口正常另一个接口报签名错误参数类型不一致检查该接口是否有数字、布尔值、数组参数确认序列化规则本地正常线上报签名错误线上代码是压缩版、参数序列化被改动对比本地和线上前端构建产物的加密逻辑手机端正常PC端报签名错误两端密钥不一致检查不同的环境配置密钥是否从配置中心获取同一个请求重复提交两次都能成功随机数防重放没生效检查Redis里nonce的过期时间是否设置键名是否在同一个Redis库偶尔报签名错误刷新一下又好了前端密钥刚刚轮换缓存了旧密钥检查密钥缓存策略确保刷新密钥后立即更新内存中的密钥6.2 调试时如何快速定位签名不一致签名校验失败最让人头疼的就是不知道前端和后端到底差在哪里。我调试了几次之后总结出一套高效的排查流程第一步在后端日志里把待签名字符串原样打印出来。在上述的makeSign方法里加一行日志记录拼接后的$str。第二步在前端的浏览器控制台也打印出前端的待签名字符串。这一步可以在SignUtil.generateSign里加一个console.log(待签名字符串, str)。第三步对比两边的字符串用专门的文本对比工具我用的是Beyond Compare或者直接在编辑器里用Compare插件一眼就能看出差异在哪里。最常见的差异是参数名大小写、参数顺序、某个值多了空格或引号。第四步如果两边字符串一模一样但签名还是对不上那问题基本出在密钥上。检查前后端用的密钥是否一致是否有隐藏的字符比如从配置文件复制时多了一个换行符。这个方法我屡试不爽。核心思想就是签名算法是确定性的相同输入必然得到相同输出所以只要待签名字符串一致结果必然一致。通过字符串对比就能精准定位是算法问题、参数问题还是密钥问题。6.3 生产环境中的日志脱敏和安全审计签名验证上线之后日志这块一定要提前规划好。创建一个单独的sign_log表来记录验签失败的情况字段包括IP、请求时间、请求路径、参数摘要不是完整参数避免记录敏感数据、失败原因。这样万一有攻击者批量尝试伪造签名你能在日志里发现规律性的特征比如同一个IP在短时间内大量失败或者某个路径持续收到验签失败的请求。日志里不要记录完整密钥、完整签名串、密码等敏感字段。待签名字符串可以记录但要注意它包含了用户参数如果是密码这类敏感参数做脱敏处理比如把password字段的值替换成***再记录。我在日志里只记录参数名列表和参数值的MD5摘要既能定位问题又不泄露明文。6.4 前端时间戳可信吗还有一个绕不开的问题前端传上来的时间戳可信吗严格来说不可信用户可以改本地时间或者直接抓包改时间戳。所以时间戳校验的目的不是杜绝一切重放而是把重放的时间窗口压缩到一个很小的范围。配合随机数机制同一个请求在5分钟内只能通过一次验签5分钟后就算随机数没失效时间戳也过期了。想要更高的安全等级可以再加设备指纹、IP白名单、用户行为分析这些风控手段但那些已经超出签名验证的范畴是另一套系统的事了。7. 我踩过的坑以及最后想说的几件事签名验证这套机制看着简单但细节非常多我在不同项目里前前后后做过好几版踩过不少坑。挑几个印象最深刻的说说。第一个坑是数组参数。早期版本我直接忽略了数组参数的签名结果某个接口传标签ID列表时攻击者把标签ID从[1,2]改成[1,3]后端完全没有察觉。后来我规定数组参数必须JSON.stringify之后参与签名这个问题才解决。第二个坑是密钥轮换。有次设计了每周自动轮换密钥的机制但前端密钥缓存没有做同步失效导致轮换那一小时里线上大量请求验签失败。后来我把密钥的有效期设置为前端获取时间24小时后端校验时间戳时如果发现时间戳在密钥旧有效期和新有效期之间就同时尝试新旧两个密钥进行验签做到无感轮换。第三个坑是服务端时间不同步。有台云的服务器时钟偏差了几分钟导致用户请求有一半报请求已过期。后来我在项目里加了NTP时间同步的定时任务同时在验签时把允许的时间偏差从300秒降到了30秒因为时间同步后偏差可以控制在几秒内安全性和稳定性兼顾。最终想说的是前端签名验证是一个投入产出比很高的安全措施。它成本低前后端各写一个工具函数、接入快一个请求拦截器搞定、效果立竿见影直接干掉一大批脚本攻击。但它不是银弹它防不住专业逆向也替代不了HTTPS传输、登录鉴权、接口限流、风控系统。合理的网络安全观是分层设防HTTPS解决传输加密签名验证解决接口完整性Token/会话解决身份认证限流解决恶意调用风控解决业务作弊。每一层做每一层的事签名验证只是其中必要但不充分的一环。我个人在实际项目里的建议是接口一上线就带上签名验证不要等出了事再补。因为如果接口已经开放出去调用方已经养成了直接用接口的习惯你再要求他们加签名推广成本会非常高。从一开始就把签名验证作为接口规范的一部分反而省事。如果这篇文章里的某个方案能帮你少走一次弯路那我觉得把踩坑的经历写出来就值了。
返回列表