自研统一认证中心CUA:JWT选型到SSO落地踩坑全记录

📅 发布时间:2026/10/10 22:09:47
自研统一认证中心CUA:JWT选型到SSO落地踩坑全记录
“cua”这个代号第一次出现在需求文档里的时候团队里好几个同事都以为拼错了。有人去代码仓库里搜了半天以为是某个开源框架的缩写结果发现就是新项目的名字。三个字母Common User Access通用用户接入中心。说白了它就是给公司内部所有系统做统一登录认证和单点登录的中间件。过去一年我大部分精力都花在这个组件上从最初的选型调研、架构设计到后来一版一版地踩坑迭代中间有不少值得记录的东西。这篇文章不讲虚的把当时的设计逻辑、落地细节以及折腾过的几个疑难杂症都摊开说一说给正在做或准备做统一认证、API网关权限、用户中台的朋友一些参考。这套东西的价值其实很直接公司内部但凡超过三个后台系统登录问题就会从“小麻烦”变成“大负担”。每个系统一套账号密码员工记不住管理员不好维护开发人员每做一套新系统就要从头写一遍登录注册和密码找回逻辑既重复又容易出安全漏洞。与其让每个业务系统各搞一套不如把这部分抽出来做成一个独立的、大家都复用的认证服务。“cua”解决的正是这样一个很具体、很日常却又特别影响效率的问题。适合读这篇文章的我猜是有类似处境的技术负责人、后端开发或架构师。你可能已经准备要自己做一套统一认证也可能刚接手一个现成的认证服务正发愁怎么改那下面这些内容应该能帮你少走不少弯路。1. 整体思路与设计定位为什么做凭什么做任何技术方案先搞明白“为什么”比“怎么做”更重要。统一认证听起来是个成熟概念网上开源方案一堆为什么要自己写自研的边界又在哪里这一节把思路的起点聊透。1.1 多系统账号割裂带来的连锁反应某公司业务扩张很快内部系统从两三个涨到了十几个财务、客服、订单管理、数据报表、运营后台、权限审核……每个系统上线初期都是“先用起来再说”账号体系基本是各个项目组自己管的。有的用自建用户表有的直接套用某个开源后台的用户模块密码规则各异有的甚至连初始密码都是一样的。结果就是三个非常现实的痛点。第一员工体验差上班要记好几套密码经常找IT重置第二安全审计无从下手根本说不清哪个账号在哪个系统里有哪些权限离职员工的账号经常漏禁第三开发效率低每个新项目都要把“注册登录、找回密码、修改密码、登录态校验”这套东西重新写一遍水平参差不齐。我印象很深的是一次权限事故一个离职了两个月的运营账号在很多系统里还能登录因为每个系统都有一套独立的账号表通知删除账号靠邮件群发漏了一个系统就漏了一个入口。那次之后管理层下决心要做统一的东西“cua”就是在这个背景下立项的。1.2 明确职责边界只做认证接入不碰业务权限立项之初我们犯过想太多的毛病有人希望把所有的用户、组织架构、角色权限全部管起来做成一个超级用户中心。但后来讨论了很久范围收窄了很多。“cua”只负责三件事用户身份认证、单点登录会话管理、接入方应用的管理与令牌签发。至于每个业务系统内部怎么定义角色、管理员怎么分配具体功能权限一概不干预。各系统自己决定“这个人能不能看某个报表”但“这个人是谁、是不是有效用户、当前登录状态是否有效”这些问题交给“cua”。这个边界很重要。业务权限五花八门强塞进同一个模型只会变成四不像。而身份认证是相对通用、稳定的部分抽出来做全局唯一认证源各业务系统通过标准协议对接既干净又不妨碍各自的灵活性。1.3 为什么没有直接用现成的开源方案调研过几类主流开源方案。功能确实全面但引入成本不低部署依赖重、学习曲线陡、定制化改造麻烦而且部分重量级特性比如联合身份联邦、复杂社交登录、可视化编排流程对我们这种内部使用场景来说是冗余的。我们需要的其实是一个轻量的、符合团队技术栈的、出了问题能很快定位的认证服务。与其花大力气去裁剪一个大而全的框架不如自己写一个核心扎实的精简实现把大多数通用场景覆盖好。事实也证明自研之后我们对内部细节的掌控度高很多后续接入新系统基本就是“复制一个应用配置改几个回调地址”就完事了。2. 核心架构与关键设计决策认证服务的核心其实不是“登录”这个页面而是登录之后的“会话管理”和“令牌信任”机制。这块设计直接决定系统的安全性、扩展性和接入方体验值得花时间抠细节。2.1 会话模型无状态JWT还是传统Session这是第一个大决策。传统Session方案简单直观服务端保存session id到用户数据的映射退出登录能立刻让session失效但问题是要维护服务端状态做多实例部署时要处理session共享要么引入分布式缓存要么做会话粘滞对基础设施有额外要求。JWT方案的思路是令牌自带全部身份信息服务端不保存状态天然适合多实例部署和水平扩展。但代价是“不能主动失效”你没法把一个已签发的JWT从用户手里收回来只能靠短过期时间或额外引入黑名单机制来弥补。最终我们选择了“JWT做主令牌 数据库refresh token做续期”的混合方案。访问令牌用JWT有效期设为15分钟保证泄漏风险窗口可控refresh token存数据库有效期7天用户在保持活跃的情况下可以自动续期避免频繁重新输入密码。这套组合的关键在于把无状态令牌的优点校验快、无共享存储和可吊销的长期凭证refresh token结合起来兼顾性能和安全。2.2 令牌签名与密钥管理对称HMAC还是非对称算法JWT签名算法上最初偷懒用了HS256也就是同一个密钥负责签名和验签。好处是实现简单、性能高坏处是验签方必须持有同一个密钥一旦应用端有私钥泄漏攻击者可以自己伪造令牌后果很严重。后来接入的应用越来越多涉及好几种语言的服务端我们改成RS256如果对接端语言生态支持不好ES256也是可选的。非对称签名的好处是私钥只保存在“cua”服务端对外只暴露公钥接入方拿公钥验签即可。这样即使某个应用端的公钥配置被篡改攻击者也伪造不了新令牌因为签名需要的私钥不在应用端。密钥轮换也做了版本机制。每把签名密钥都有一个kid标识JWT头里带上这个kid验签方先根据kid找到对应公钥再做验签。“cua”支持同时存在两份活跃密钥轮换时新密钥先上线等旧令牌自然过期再删旧密钥始终有备份可用不会因为轮换造成服务中断。2.3 密码存储与登录保护策略密码这块没有悬念直接用的bcrypt成本因子设为12。为什么不用MD5、SHA256加盐因为这两种算法设计目标就是“快”攻击者能用GPU并行爆破加再多盐也拦不住。bcrypt则是刻意设计成慢的单次计算上百毫秒攻击者爆破的成本指数级增加。成本因子12在当前硬件上大约是单次几十到一百多毫秒登录频率不高这个延迟可接受但安全性高一个数量级。除了加密还做了登录保护同一账号连续失败5次就要求额外输入图形验证码连续失败15次锁定账号半小时。这是很基础的规则但能挡住绝大多数密码爆破攻击。要注意的是锁定操作的实现要避免攻击者用“故意错误登录”来锁定别人的账号我们采取的是“IP账号”组合维度的风控同一IP五分钟内失败次数异常时优先锁定IP维度避免误伤正常用户。2.4 接入方SDK设计自动续期与状态管理接入方SDK是业务系统集成“cua”的主要方式设计得好不好直接决定接入体验。除了最基本的“拿授权码换token”之外SDK里最重要的一块是自动处理refresh token续期。一个典型场景用户登录了某后台应用放着两小时没动回来继续操作时访问令牌早过期了。如果没有自动续期SDK直接拿已过期的令牌去调用接口业务方会收到401体验很差。我们的SDK内部维护一个令牌管理器调用业务接口前自动检查“当前令牌剩余有效期是否小于5分钟”如果小于就直接用refresh token换一张新令牌业务方完全无感知。这样用户只要每天至少操作过一次系统基本不会被迫重新登录。SDK还做了时间的容错处理JWT里的exp过期时间戳和本地时间比较时允许最多30秒的时钟偏差leeway避免某台服务器时间稍有偏差就导致误判这个细节在后面的坑里再展开说。3. 核心流程与关键模块的落地实现设计归设计落地实现时有很多“细节里面出魔鬼”的地方。这节按数据模型、核心流程、校验中间件和接入配置几个核心模块展开把关键参数和设计原因都交代清楚。3.1 数据模型用户表、应用表与refresh token表三个核心表用户表、接入应用表、refresh token表。用户表就是最基础的账号信息密码字段使用bcrypt哈希一个用户可以有多个邮箱或手机号但至少要有一个用于登录的唯一标识。接入应用表记录了每个业务系统的基础信息应用名称、回调地址列表、应用标识client_id、应用密钥client_secret仅存哈希、令牌有效期覆盖配置、白名单IP段。这里回调地址一定是全量白名单不能只存一个字符串因为实际接入时经常出现“本地调试环境回调查本机”“测试环境放内网IP”这类诉求把多个合法回调地址都配进来才不会被回调地址校验环节卡住。refresh token表则记录了用户与应用之间的“长期登录凭证”关键字段包括用户ID、应用ID、token哈希、过期时间、使用次数、创建时间。token本身只存哈希值即使数据库泄露也无法直接利用。表上建了“用户ID应用ID过期时间”的组合索引用来支持“用户退出某应用”“管理员强制退出某用户全部会话”这类常见诉求。3.2 登录与授权码流程OAuth2授权码模式加PKCE内部系统之间的认证我们采用OAuth2的授权码模式authorization code flow。为什么不用更简单的密码模式client credentials密码模式要求业务系统持有用户密码去换令牌等于每一个接入方都变成了一个密码采集器安全风险很大。授权码模式下用户密码只经过“cua”登录页业务系统永远接触不到明文密码令牌交换也发生在服务端到服务端之间更安全。考虑到部分旧系统对接能力有限我们对授权码模式加了PKCEProof Key for Code Exchange的支持。PKCE的本质是让客户端在发起授权请求时生成一个随机的code_verifier并提交其哈希值code_challenge换取令牌时必须带上原始code_verifier做校验。这样即使授权码被截获攻击者没有code_verifier也换不到令牌。对于纯前端的SPA应用和移动端来说这个机制是防止授权码被滥用的关键。授权码本身只存活60秒且明确约定一次性使用。兑换令牌的接口里校验步骤的先后顺序也很有讲究先校验client_id和client_secret校验不通过直接拒绝再校验授权码是否存在且未使用校验通过后在同一数据库事务里把授权码标记为已使用再签发令牌。顺序不能反因为先消费再校验会把一个有效但参数错误的码也吃掉可能导致用户重试时莫名其妙失败。3.3 令牌校验中间件一次请求从进入到放行的全过程业务系统接入“cua”后所有受保护接口的请求头里都会带上Bearer Token。令牌校验中间件的工作流程分五步每步都有明确目的。第一步从请求头里取token格式不对直接返回401第二步解析JWT校验签名算法是否在白名单内防止攻击者用none算法绕过签名校验以及签名是否有效第三步校验令牌是否过期读取exp、iat、nbf生效时间等字段允许30秒clock skew第四步校验issuer签发者和audience受众是否匹配当前应用第五步查Redis黑名单看这个token的jti唯一标识是否被吊销。这里有个容易被忽略的点签名算法白名单。早期我们只在验签时写“用公钥验签”没有限制算法结果安全扫描报告提示存在算法混淆攻击风险——攻击者可以构造一个HS256签名的JWT如果服务端误用公钥内容去当HMAC密钥验签就能绕过签名验证。修复方法就是把算法白名单做成硬编码明确只接受RS256。这个教训希望大家都记住验签之前先看算法是否允许。3.4 接入应用配置与本地调试的最佳实践新系统接入“cua”的流程我们尽量做成自助化在管理后台创建应用、填写回调地址、生成client_id和client_secret、下载对应语言的SDK。但实际推动过程中发现开发人员最容易卡住的是本地调试环境。本地开发时前端跑在localhost:8080后端跑在localhost:9090回调地址到底是哪个我们的建议是本地调试阶段在“cua”后台多配一个回调地址指向localhost:8080/callback同时把客户端的hosts里加一条本地映射到开发环境的域名配置。这样登录成功后还是通过正式域名体系跳转cookie作用域不会乱。另外一个隐藏点是如果本地用HTTP调试但“cua”生产环境强制HTTPS那么回调地址需要特别处理不然会出现“协议不匹配”的报错。我们最终统一要求所有环境至少配一层TLS回调地址只接受HTTPS本地调试走本地代理把HTTP升级成HTTPS从根上杜绝这类问题。4. 实际运行中的踩坑记录与排查思路上线只是开始真正磨人的是各种边界情况。这节挑几个印象最深的故障记录当时的现象、排查过程和处理方案比任何文档都有价值。4.1 令牌还未过期就报401时钟偏差引发的“灵异事件”有段时间频繁有人反馈明明刚登录没多久突然就401了重新登录又好了。排查发现出问题的都是某个机房老旧的服务器系统时钟慢了将近200秒。JWT验签时本地时间戳比签发方时间早导致exp看起来“提前到期”从而把有效令牌判成过期。这个问题的经典解法是允许“时钟偏差”leeway我们验签时把过期判定改成“当前时间是否晚于exp 30秒”。但治本之策是同步系统时间所有服务器统一配置NTP同步并加上监控报警但凡本机时间和标准时间偏差超过10秒就自动告警。这两手都做了之后这类问题基本绝迹。4.2 单点退出形同虚设无状态JWT的“幽灵会话”上线后发现一个特别尴尬的情况用户点“退出登录”前端把本地token清掉了但只要别人复制过他之前请求里的JWT拿着旧token照样能调接口。JWT自身的“无状态”特性决定了签发之后就很难主动失效这对退出场景是硬伤。我们的方案是引入黑名单机制用户主动退出、修改密码、管理员强制下线时把对应JWT的jti存入Redis设定TTL为“这个JWT的剩余有效时长”。验签中间件在通过签名和过期校验后还要查一下这个jti是否在黑名单里。这个方案保留了JWT的核心优点只是牺牲了一点存储空间用Redis内存换来了主动吊销的能力实测调用量下毫无压力。需要注意的一点是TTL不能设太长不然Redis会被无用的jti堆积撑爆也不能太短否则剩余有效期内的旧令牌会漏掉。正确做法就是精确设置为“该token的exp时间减去当前时间”。4.3 授权码被重复兑换高并发下的原子性问题一次大促活动前压测发现登录接口偶尔报“授权码已使用”。查日志发现是同一个授权码在极短时间内被提交了两次兑换请求一个成功一个失败。原因很直白第一次请求查“授权码未使用”后还没标记成已使用第二个请求又走进来也看到这个码是未使用状态两个请求都能通过校验数据库里又只有一条记录最终一个成功一个报错。这个问题的标准解法分两层数据库层面给授权码字段加唯一索引让它成为天然的“消费锁”服务层面在兑换逻辑里改用“UPDATE 表 SET status‘已使用’ WHERE authorization_code? AND status‘未使用’”这种原子操作通过影响行数判断抢没抢到。两层都做了之后重复兑换会有一个请求成功其余全部拒绝不会再出现两个都通过校验的情况。4.4 连接池被打满一次慢查询拖垮全部登录请求还有一起比较隐蔽的故障某天登录接口突然大量超时数据库连接池被打满业务系统也开始调用失败。通过慢日志分析发现罪魁祸首是一条查询refresh token的SQL当时用userId和appId查询时没走到索引而refresh token表已经积累了上百万条历史数据每次全表扫描花了几秒钟并发一上来数据库就扛不住了。修复方案很常规给关联字段建组合索引把慢SQL的执行计划彻底优化。但这次故障暴露了一个更大的架构隐患任何依赖数据库的认证步骤都可能成为瓶颈。所以我们后来把“当前登录会话查询”这一高频操作做了缓存优化查询refresh token之前先查Redis缓存命中则直接返回有效降低数据库压力。这个改动让高峰期登录成功率显著提升属于值得提前就做的预防性措施。5. 上线后的优化方向与扩展思考系统稳定运行了几个月后接入方越来越多一些新的需求和优化点也陆续被提上日程。5.1 SDK的多语言生态与“零依赖”降级方案起初官方SDK只有Java和Python两个版本后来接入方反馈需要Go、Node.js版本我们陆续补齐并尽量确保各版本行为一致。即使接入方不想使用SDK“cua”的整套接口文档也坚持标准化设计对接方完全可以用HTTP工具直接调用。有个例外值得说明如果业务系统不想做全套授权码流程只想快速验证令牌是否有效我们额外提供了一个“令牌校验API”使用网关自身的client_credentials凭证调用传入任意JWT即可获得该令牌是否有效、属于哪个用户等基本信息。这个接口对一些简单的内部工具系统非常友好接入成本极低。5.2 多因素认证与安全加固的下一步很多内部系统已经开始要求敏感操作必须做二次验证比如导出批量数据、修改核心配置、重置他人账号密码等。目前“cua”已经预留了多因素认证的扩展点用户可以在安全设置里绑定TOTP码基于时间的一次性密码敏感操作时服务端强制要求动态验证码。下一步我们计划接入企业微信或钉钉扫码登录方便员工在手机上完成认证减少额外密码记忆负担。不过这属于用户习惯和行政推动的问题技术本身并不复杂需要协调的其实是各业务方的接入排期。5.3 运维监控与数据洞察最后想提醒大家认证服务是基础设施必须当成“准生产服务”对待。我们上线前就接入了全套指标监控登录成功率、各应用令牌签发量、令牌过期率、refresh失败原因分布、黑名单命中次数、各接口耗时百分位。一旦指标异常报警会立刻推送到值班群。我们还基于登录数据做了几类数据分析每天登录人数、活跃用户数、各系统活跃分布。这些数据对后续判断“哪些系统确实需要单点登录”“哪些系统其实已经很少人用可以下线”很有参考价值。结尾几点真实的个人体会项目折腾下来我个人的感受其实很简单技术方案的难点从来不在某个算法的实现而在边界划分和取舍判断。如果一开始我们把“统一认证”无限膨胀成“统一用户中心”这个项目大概率到现在还在讨论需求。坚决把范围钉在“认证与单点登录”这一个点上团队才能在有限时间交付一个稳定可用的东西。另一个体会是接入方体验决定了这个基础设施能走多远。认证服务即使技术再完美如果接一个系统要花三天、文档里坑坑洼洼、报错信息含糊其辞开发者自然会抵触。我们后来花了很多精力在SDK易用性、错误信息可读性和文档示例的完善上投入产出比非常高。如果让我重来一次我可能会在项目启动前就把监控告警体系搭起来而不是等人反馈问题。基础设施类服务的故障感知前移很重要每早一分钟发现故障可能就少影响一整条业务线。这套“cua”体系现在已经稳定跑在十几个系统之上对我来说它最大的价值不是代码本身而是过程中想清楚的这些问题。希望这篇文章对同样在折腾认证机制的你有点帮助。