☰
自研统一认证中心CUA:JWT选型到SSO落地踩坑全记录
2026/10/10 22:09:35 网站建设 项目流程

“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”登录页,业务系统永远接触不到明文密码,令牌交换也发生在服务端到服务端之间,更安全。

考虑到部分旧系统对接能力有限,我们对授权码模式加了PKCE(Proof 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”体系现在已经稳定跑在十几个系统之上,对我来说,它最大的价值不是代码本身,而是过程中想清楚的这些问题。希望这篇文章对同样在折腾认证机制的你有点帮助。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询