
干了十几年企业级系统从传统单体应用到现在的云端SaaS感触最深的一点就是客户关系管理系统这类东西表面上比的是功能菜单谁家全实际上到了真正落地部署的时候比的全是安全、集成和边界处理。DeskcommCRM我前后跟了好几个项目从最初的客户管理、销售漏斗、工单流转到后面陆续接呼叫中心、AI外呼、短信平台再跟钉钉、企业微信做深度集成功能链越来越长跨域调用的场景也越来越多。尤其是第三方页面要在自己的域名下展示CRM客户数据外部系统要回调工单状态、外呼结果移动端、PC端、Web端多端联动这些需求一旦堆上来传统那套基于Session和Cookie的权限模型立刻就不够用了。这篇文章不聊功能清单就聊这次做跨域安全加固的完整思考过程从登录态模型怎么改到token怎么替换uid再到请求签名防篡改、服务端校验链路怎么做以及云环境下无状态会话的坑。基本是按项目复盘的方式写里面所有踩过的坑都是真实发生的希望能给正在做同类系统的朋友省点时间。1. 加固背景为什么传统会话模型在跨域场景下撑不住先交代一下DeskcommCRM本身。它的核心模块包括客户信息管理、销售漏斗、大客户管理、合同订单、工单管理、产品目录、财务数据和经营报表同时还内嵌了呼叫中心、AI外呼机器人、短信平台这些偏通信的能力。跟钉钉整合后能同步通讯录、待办、消息和审批流程跟企业微信整合后能管理外部联系人、群发消息和聊天侧边栏。这套系统一旦铺到真实企业环境里立刻会面临一个很现实的问题企业不一定只在CRM系统内部操作数据。我遇到过的典型场景有这么几类企业有自己的官网或者H5活动页需要把在页面里收集到的线索直接写入CRM的获客渠道。管理层希望在企业微信或钉钉的工作台里直接看到CRM的销售漏斗和合同审批。呼叫中心外包坐席在第三方平台登录外呼结束后要把通话记录、录音地址、意向标签回传到CRM系统。售后服务商需要在异域环境下查询工单状态、提交处理结果。这些场景说白了都是跨域。跨域之后浏览器的同源策略不会帮你管理身份Cookie能不能带过去看对方配置Session在分布式环境下本身就不稳定再叠加云环境里公网访问地址可能随时变化的问题整个信任链条就断了。我做安全加固的第一件事不是急着写加密代码而是先说服项目组接受一个前提跨域环境下不能再依赖浏览器帮我们记住登录状态这种隐式信任必须改成显式的、每次请求都校验的信任模型。也就是把身份认证从环境层面下沉到请求层面。2. 登录态模型演变从uid裸奔到token双向校验2.1 原来的问题uid直接跨域传递早期系统里跨域调用的身份标识用的就是uid。前端登录后拿到用户uid后续所有跨域请求都在参数或者header里带这个uid后端拿着uid去判断用户是谁、有没有权限。这套方案在内部系统里跑没问题但一旦暴露到跨域环境问题非常明显uid是长期有效且不变的泄露一次就等于账号永久泄露。uid是明文传输的抓包就能看到根本不用解密。第三方调用方不需要知道密码只要偷到一个有效uid就能模拟任意用户操作。我记得有一次排查线上问题发现某个合作方的前端代码里直接硬编码了一个uid用来拉取CRM客户列表。当时后背就有点发凉这个uid是什么时候泄露的、被多少人拷贝过完全不可控。2.2 加固后的模型uid替换为带时效token这次加固的核心思路是把跨域请求中的身份标识从永久uid替换为带时效的token。具体逻辑是这样的系统在运营后台创建用户映射时不再直接暴露uid而是由服务端根据用户身份信息生成一个token这个token包含有效期、用户标识、来源域名等关键信息并且使用AES对称加密后再参与签名计算。前端或者第三方调用方在发起跨域请求时请求头里带的是token而不是uid。服务端收到请求后先从请求头取回明文token解密后得到带时效的内嵌令牌再从令牌中解析出可信用户信息。整个过程中uid只在服务端内部流转外部拿不到。为什么要保留uid到token的映射关系因为DeskcommCRM内部大量的业务表结构里存的就是uid比如客户归属、工单处理人、合同创建人这些字段。改造的时候不可能把所有表都重构一遍所以采取了外部用token内部用uid的策略在边界层完成转换。2.3 双向校验机制我这里说的双向校验不只是服务端校验客户端token还包括客户端对服务端身份的确认。具体实现分两层第一层服务端通过RSA非对称加密生成token签名客户端拿到token后用公钥验证token确实是目标服务端签发的防止第三方伪造服务端接口诱导客户端提交数据。第二层服务端回调第三方接口时同样会对请求体做签名第三方用事先约定的公钥验证回调请求的真实性。这样数据在两端之间流转时任何一端都不需要盲目信任对方。整个链路用的是AES对称加密加密业务数据、RSA非对称加密做身份签名、TLS保证传输过程中不被窃听。AES快适合大量业务数据RSA慢但安全适合做签名和密钥交换TLS是底座避免前面两层做了半天结果传输层裸奔。3. 请求签名与防篡改不只是防篡改还要防重放3.1 签名计算的核心逻辑跨域请求里面临的最大威胁不是数据被加密后看不到而是请求被篡改、被重放。比如第三方调用方把一个查询自己客户的请求改成查询全部客户或者把一个提交工单的请求重复提交十次。针对这个问题我设计了一套请求签名机制客户端对请求体body做标准化处理取关键参数按固定顺序拼接成待签名字符串。使用AES密钥参与摘要计算生成签名值放进请求头header的sign字段。服务端收到请求后按照同样的规则重新计算签名对比本地生成的sign值。如果两个值不一致直接拒绝请求。这里有一个特别容易踩坑的点签名计算的字段顺序必须完全一致。我和团队有一次排查了半天发现客户端和服务端算出来的签名始终对不上最后定位到是某个字段在拼接时客户端用的是JSON序列化后的顺序服务端用的是对象属性的顺序两边不一样。后来我们做了个约定所有参与签名的字段一律按照字典序排序后再拼接并且统一用keyvaluekeyvalue的格式这样不管哪端用什么语言实现只要按规则来结果就是一致的。3.2 时间戳防重放单纯防篡改还不够因为攻击者可以把一个合法请求原封不动地重复发送。比如一个确认收款的请求被重放两次就可能导致重复入账。所以签名机制里必须加入时间戳。客户端在请求头里带一个timestamp参数服务端校验时检查这个时间戳与服务器时间的差值超过一定范围我们设的是5分钟就直接拒绝。这样即使请求被完整截获攻击者也没法在超过时间窗口后继续重放。再进一步对于一些敏感操作比如合同审批、退款确认服务端还会用Redis记录请求的唯一标识requestId如果同一个requestId在短时间内出现两次直接判定为重放请求予以拒绝。3.3 自定义注解实现签名校验签名校验如果每个接口都写一遍那代码就废了。我用自定义注解加拦截器的方式来解决这个问题。定义了一个SignValidate注解在需要做签名校验的Controller方法上标注即可。拦截器通过HandlerMethod拿到当前方法是否有这个注解如果有就执行签名校验逻辑。这样做的好处是让业务代码保持干净安全逻辑统一收口在拦截器里。新增一个跨域接口时开发者只需要在方法上加一个注解不用关心签名怎么算、怎么验。项目里有几十个跨域接口靠这个机制统一管住了。注意拦截器在处理请求时如果直接在拦截器里读取了HttpServletRequest的body流controller里就拿不到参数了。这是个经典坑。解决方案是用ContentCachingRequestWrapper包装请求让body流可以被重复读取。4. 从uid到token用户映射的服务端实现4.1 用户映射的创建流程在组织协同版本里用户映射的逻辑是从adminToken开始的。具体流程我梳理一下运营后台创建数据时携带adminToken发起请求。服务端校验adminToken的合法性确认调用方是受信来源。从请求中解析account、name、uid等用户信息。服务端生成带时效的token将token与uid的映射关系存入Redis。验证通过后返回一个回包包含token、过期时间、刷新凭据等信息。后续跨域请求携带这个token服务端解密token即可还原出用户身份。这套流程看起来不复杂但里面有个关键设计token是有时效的过期后需要续期。如果不做续期用户的体验会很差每隔几小时就要重新登录一次如果续期做得不好又会出现并发续期导致token频繁刷新的问题。4.2 token续期与并发控制续期机制我采用的是双token思路一个短期token用于实际请求校验有效期2小时一个长期refreshToken用于续期有效期7天。当短期token过期时客户端携带refreshToken请求新的token。这里面有个坑叫续期风暴。当token快要过期时如果客户端同时发起了多个请求每个请求都会发现token过期然后各自去续期结果就是同一时间产生大量续期请求服务端压力瞬间飙升。我的处理方式是用Redis做续期锁。客户端续期时先尝试获取锁获取成功的才真正执行token刷新获取失败的等待一小段时间后重试。这样即使有并发续期请求最终也只有一次真正刷新token其他请求拿到新token后继续执行。4.3 自定义注解与Redis缓存用户映射关系我存储在Redis里key是tokenvalue是用户信息JSON字符串过期时间跟token有效期一致。查询用户信息时先查Redis查不到再查数据库查到了回填Redis。自定义注解在这块也有用途。我定义了一个RequireToken注解用于标识需要token校验的接口。在拦截器里如果检测到目标方法有这个注解就会从请求头Authorization中取出token解密校验有效性然后把用户信息放入ThreadLocal供后续代码直接使用。ThreadLocal用完一定要清理不然在高并发下会出现用户数据串线的问题。我在拦截器的afterCompletion方法里做ThreadLocal的remove操作这个细节很重要。5. 无状态云环境下的会话与快照方案5.1 云环境带来的新挑战传统的会话管理假设客户端和服务端的网络环境相对固定Session存在单台服务器内存里就好。但到了云环境一切都不一样了公网访问地址随时可能变化无法通过IP白名单来限制访问来源。多台服务器批量创建服务实例动态伸缩Session存在单机内存里等于自杀。服务无状态化是上云的基本要求任何状态都要外置到Redis等分布式存储上。这意味着不仅用户登录态要无状态化连用户当前在哪个页面、正在做什么操作这种会话上下文信息也要想办法外置。5.2 会话快照机制我实现的方案是做会话快照每隔一段时间把用户当前的访问信息包括所在页面、操作对象、关键参数、token状态存一份到Redis。当用户切换设备或者token过期重新登录后可以从快照中恢复上下文。比如DeskcommCRM的工单模块里客服人员正在处理一个客户投诉工单填到一半去接了个电话回来发现token过期被踢下线。有了会话快照机制重新登录后系统能自动恢复之前的填写内容不用从头再来。这个功能对于呼叫中心坐席特别有用。坐席在CRM里记录通话内容时经常需要在多个页面之间来回切换如果每次切换都丢失上下文工作效率会大受影响。5.3 请求令牌的过期与续期请求令牌的过期时间我设置为30分钟比登录token的2小时短很多。因为请求令牌是每次请求都要校验的过期时间短一些令牌被泄露后的危害窗口就小一些。续期策略分成两级请求令牌过期后用登录token换新的请求令牌登录token过期后用refreshToken换取新的登录token和请求令牌。两级续期都失败才要求用户重新登录。这里要特别注意续期的粒度。如果每过30分钟客户端就静默续期一次服务端的压力不会太大因为续期本身是个轻量级操作。但如果不做任何缓存策略每个续期请求都去数据库查一遍用户信息高并发下很有可能会把数据库打崩。我的做法是续期请求在Redis里更新用户信息时用hash结构存储只更新有变化的字段尽量避免大对象的序列化和反序列化。6. 跨域页面访问栈信息梳理与可信来源校验6.1 为什么需要梳理访问栈跨域场景下一个页面可能嵌入了多个来源的内容比如DeskcommCRM的第三方合作页面里既展示CRM客户数据又调用了企业微信的聊天侧边栏还挂着一个独立的呼叫中心软电话面板。这种情况下如果不对访问栈做梳理根本不知道一个请求到底是从哪个页面发起的。我做过一次访问栈梳理拿实际的项目举例某个客户的官网首页嵌入了CRM的线索收集组件线索收集组件里又有AI外呼的回调入口AI外呼完成后回调企业微信的群机器人群机器人又把消息推送到钉钉工作台。这个链路上任何一环被伪造都会导致线索数据被污染。所以我们要求用户映射列表里的创建者以及回调API的调用来源必须指向官方应用信任的域名或服务器。先建立白名单再处理业务逻辑。6.2 Referer校验与Origin校验的局限性有人可能会说跨域安全直接用Referer和Origin校验不就行了这两个头确实是浏览器自动带的但有两个问题Referer和Origin可以被修改尤其在非浏览器客户端比如后端服务间调用中这两个头完全由调用方自己控制。很多网关或代理在转发请求时会丢弃或重写这两个头导致服务端拿到的Referer不可信。所以我的做法是把这些头部信息作为辅助手段不是当作唯一依据。真正起决定性作用的还是token本身和签名校验。6.3 可信来源校验的最终方案最终的可信来源校验方案分三层第一层校验调用方的域名是否在用户映射表的白名单里。白名单由运营后台维护只有管理员能配置。第二层校验回调API的调用来源是否匹配。这个匹配是在服务端完成的通过解析token里的来源域名信息和实际请求的来源地址做比对。第三层校验请求签名是否合法。这个前面已经详细说过了不再重复。三层校验都通过这个请求才算真正可信。7. Authorization头域携带与token传递规范7.1 为什么放在Authorization头登录令牌我放在请求头的Authorization域中格式是固定的Authorization: Bearer token。这个格式是业界标准很多网关和框架都默认支持解析这个头。为什么不用Cookie核心原因是跨域环境下Cookie的携带策略非常不可控。域名不同浏览器默认不会携带对方的Cookie就算配置了CORS允许跨域Cookie的SameSite属性设置不对照样带不过去。与其在Cookie上跟各种浏览器特性做斗争不如直接在请求头里显式传递简单直接所有端的行为一致。7.2 网关截获Authorization头的坑这里有一个很典型的坑必须分享某些网关会把Authorization头截留下来用于自己的身份认证不会转发给后端服务。遇到这个问题的时候我们的服务端已经调试通过了但一上生产环境就发现token总是校验失败。排查了很久最后发现是网关把所有请求的Authorization头都替换成了网关自己的token后端拿到的根本不是客户端传过去的token。解决方案有两种一种是在网关层面配置白名单对指定路径的请求放行Authorization头另一种是改用自定义头比如X-Token或X-Auth-Token绕开网关的默认处理逻辑。我在一个项目里用的是自定义头方案改动最小也最省心。7.3 多端消息联动的token携带DeskcommCRM集成了钉钉和企业微信多端消息联动是标配能力。比如钉钉审批通过后同步更新CRM里的合同状态企业微信聊天侧边栏里查看客户信息时需要从CRM拉取客户数据和跟进记录。这些场景里token的携带方不是浏览器而是钉钉或企业微信的服务端所以要采用应用凭证换取用户token的模式钉钉或者企业微信的服务端通过自己的API换取用户的unionId或userId。CRM系统根据unionId或userId查找对应的映射记录生成临时的请求token。第三方应用携带这个请求token调用CRM接口完成数据查询或更新。这套链路处理好了之后用户在企业微信里打开聊天侧边栏点击一个客户头像能直接看到CRM里这个客户的历史跟进记录、合同状态和最近通话体验非常顺畅。8. 踩坑实录这些坑你可能也会遇到8.1 AES密钥硬编码风险早期实现里AES密钥是直接写在项目配置文件里的。后来做安全审计时发现代码仓库的权限控制并不严格前外包开发人员、第三方驻场人员都可能接触到源码密钥一旦泄露整个加密体系就形同虚设。现在的做法是AES密钥通过环境变量注入不在任何配置文件里明文出现。每次部署环境单独生成密钥测试环境、预发环境、生产环境的密钥互相隔离。更稳妥的做法是使用密钥管理服务KMS不过考虑到私有化部署的客户环境差异很大不是每个客户都能提供一个KMS服务所以环境变量注入算是一个性价比最高的折中方案。8.2 签名计算字段顺序不一致签名计算的字段顺序坑我在前面已经提到过这里再展开说一下具体的表现。客户端用Java的HashMap存储参数签名时直接遍历HashMap拼接字符串服务端用TreeMap存储参数按自然顺序输出。结果两边计算出来的签名值完全不同。排查到这个问题时整个项目组都很崩溃因为代码本身没有bug就是数据结构的输出顺序问题。固定解法是签名前把所有参数按照key的字典序重新排序然后以keyvaluekeyvalue的格式拼接最后再计算摘要。这个规则必须在接口文档里写清楚并且提供示例代码让调用方照着做。8.3 拦截器里读body流后controller拿不到参数这个坑非常隐蔽。在拦截器里做签名校验时需要读取请求体的原始内容来计算签名。但Servlet的ServletRequest的InputStream是一次性的读完之后controller层再尝试读取请求体得到的就是空。报错现象很典型接口签名校验已经通过了但controller里接收到的是一个空对象或者全是null。因为参数解析是在框架层面完成的它也要从InputStream里读数据但流已经被拦截器消费完了。解决方案就一个使用ContentCachingRequestWrapper包装原始的HttpServletRequest它的核心作用是缓存body内容让多次读取成为可能。无论是签名校验还是controller参数解析都能从缓存中拿到数据不会再出现流被消费的问题。8.4 token续期风暴token续期风暴在预发环境就踩到了。客户端有多个并发请求共用同一个tokentoken过期时所有请求并发发起续期一瞬间打了几十个续期请求到服务端。虽然服务端抗下来了但响应时间明显增加用户体验受到很大影响。解决方案就是前面提到的续期锁。但这里有个细节不能直接用分布式锁的框架因为续期请求本身要非常快锁的获取和释放不能太重。我用Redis的SETNX命令实现了一个轻量级互斥锁设置2秒的过期时间续期逻辑执行完就释放锁没有释放就等锁过期自动释放。8.5 云环境公网访问地址变化私有化部署在云环境时公网访问地址可能会因为负载均衡器重新创建、弹性IP调度而发生变化。如果代码里硬编码了回调地址一旦地址变化回调就彻底断了。我踩过这个坑后定了一条规则凡是涉及回调地址的配置一律走配置中心或者数据库字典表禁止硬编码。系统启动时从配置中心拉取回调地址运行期间如果地址发生变化通过管理后台手动更新更新后立即生效不需要重启服务。8.6 跨域携带Cookie问题这是个在开发环境永远测不出来、一上生产就出问题的场景。开发环境下前端和后端在同一个域名下Cookie携带没有任何问题。但生产环境的前端部署在静态资源服务器上后端是独立的API服务器域名不一样Cookie就不带了。即使后端配置了CORS头也还受限于Cookie的SameSite属性。如果SameSite设置成Lax或者Strict跨站请求就不会带Cookie。我在最后一个客户现场被这个问题卡了两天最终是用请求头携带token的方式彻底解决Cookie只用于登录验证码这种一次性场景。9. 第三方调用方接入规范与实践落地9.1 接入流程文档化安全机制做得再完善如果外部调用方不知道怎么对接一样白搭。我总结了一套第三方调用方的接入流程写进《跨域接口接入规范》文档里提交调用方信息包括企业名称、应用名称、回调地址、需要访问的数据范围。运营后台创建用户映射分配appId和appSecret配置可访问的接口白名单。获取token调用方用appId和appSecret换取访问tokentoken有效期2小时。数据签名请求体用AES密钥加密后参与签名计算签名值放在header的sign字段。发送请求请求头携带Authorization、sign、timestamp、requestId等字段。接收回调服务端调用第三方接口时同样遵循签名规则第三方需验证签名后处理数据。9.2 与DeskcommCRM核心模块的配合场景前面说的这些安全机制最终要落到具体业务场景里。我挑几个典型的说说呼叫中心外呼回调坐席在第三方软电话上发起外呼通话结束后软电话调用CRM接口回传通话记录、时长、录音地址、客户意向标签。这些数据属于敏感业务数据必须走完整签名校验链路。我接入过的项目里通过这个接口把外呼结果实时同步到CRM客户档案销售跟进的时候能看到每一次通话的完整记录。工单跨域推送售后服务商在自己的系统里处理工单处理完成后把结果回传到CRM。如果这个接口没有安全防护攻击者可以伪造工单状态破坏整个售后服务流程的完整性。加上签名和token校验后售后工单数据在异域系统间流转时保持一致和可信。AI外呼机器人的数据回流AI外呼完成后机器人平台需要把对话文本、意向分类、通话摘要回传到CRM。这类接口通常是高频调用因为外呼量大的时候一天可能回传几万条记录。签名校验不能成为性能瓶颈所以在实现时对签名计算做了缓存优化同一请求体内容在短时间内重复计算签名时直接复用缓存结果。9.3 非对称加密在密钥交换中的使用很多人问既然用了AES对称加密为什么还要用RSA非对称加密直接AES一把梭不就好了答案是AES加密需要双方共享同一个密钥但这个密钥怎么安全地分发给对方本身就是一个问题。如果直接通过网络传输明文密钥被截获的可能性很大。所以实际的方案是RSA密钥对由服务端生成公钥下发给客户端客户端用公钥加密AES密钥后回传服务端用私钥解密得到AES密钥。之后的数据传输都走AES加密RSA只在密钥交换阶段起作用保证AES密钥不会在网络上明文传输。10. 常用的技术选型与实现要点10.1 Redis在安全机制中的角色Redis在整套安全机制里承担了四个角色token存储存登录token、请求token、refreshToken以及对应的用户信息。签名防重放存requestId防止同一请求被重复提交。续期锁用SETNX实现轻量级互斥锁防止续期风暴。会话快照存用户当前访问状态满足无状态云环境下的上下文恢复需求。Redis的性能很关键。token校验是每个请求都会走的路径Redis的响应时间直接影响接口的整体延迟。我在压测时发现Redis的读延迟超过1毫秒时接口整体的P99响应时间就会明显变差。所以生产环境尽量让Redis和业务服务部署在同一个内网网段避免跨机房访问。10.2 MyBatis-Plus在用户映射查询中的使用用户映射表的查询在用户映射创建、token校验、用户信息变更等场景里大量使用。MyBatis-Plus在这个模块里主要负责用户映射的CRUD操作但有一点需要注意MyBatis-Plus的LambdaQueryWrapper虽然方便但如果使用不当容易造成严重的全表扫描。比如用户映射表按照uid查询时如果没有给uid字段建索引数据量一大查询就会超时。我遇到过的一个客户场景是用户映射表里有几十万条数据查询耗时从几十毫秒直接劣化到几秒钟。解决方式也简单给查询频繁的字段加索引并且用MyBatis-Plus的分页插件防止一次拉取过多数据。10.3 自定义注解的具体实现思路自定义注解有两个分别是SignValidate和RequireToken。SignValidate用于需要签名校验的接口拦截器在handler执行前判断方法上有没有这个注解有的话执行签名校验逻辑。RequireToken用于需要登录态校验的接口拦截器从Authorization头里取token解密后把用户信息放入ThreadLocal。两个注解可以组合使用比如一个接口既需要登录态又需要签名校验就同时标注两个注解。拦截器的执行顺序是先校验签名再校验token两者的顺序有讲究先确认请求未被篡改再确认请求者的身份如果请求本身都被篡改了校验身份就没有意义。11. 写在最后的经验沉淀这套跨域安全加固方案从设计到落地前后迭代了两个多月。核心的加密、签名、token机制都是现成的技术方案真正花时间的反而是在业务链路上把每个环节的细节理顺。我个人最大的体会是安全加固不能做成一刀切的全封闭体系而是在保证安全底线的同时给外部调用方留出合理的接入空间。DeskcommCRM这种多模块、多端的系统如果安全方案做得过于严苛外部协作方接入成本太高业务推进就会受阻如果做得太松数据泄露的风险又让人睡不安稳。现在这套机制上线后我养成了一个习惯每周固定看一次安全审计日志关注token异常、签名失败、白名单外域名调用这几类事件。有一次就是通过签名失败日志发现某个合作方的服务器时间不同步导致签名里的时间戳校验全部失败提前规避了一次线上事故。最后再分享一个小技巧不管你的安全方案做得多完善接口文档里一定要提供签名计算示例和常见错误码说明。跨域对接的大多数问题最后都出在调用方没有完全理解签名规则上。一份好文档能帮你减少至少一半的联调沟通成本。