ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

电商系统统一认证方案设计:JWT令牌与网关校验实践

电商系统统一认证方案设计:JWT令牌与网关校验实践

2. 需求切入:电商系统为什么要做统一认证

做过电商后台或全渠道零售项目的同学,应该都经历过同一个阶段:用户中心一套登录,订单系统一套密码,积分商城又是一个账号,甚至同一个用户在微信里登录了一次,回到App还得再输一遍验证码。表面上只是体验割裂,实际上背后埋着更大的坑。

我在某个电商项目里接手时,线上光是登录逻辑就有三套独立的实现。用户体系分散在会员库里,订单库也有简化版的账号表,营销系统的账号干脆是独立自建的。那时出了个很典型的线上事故:用户下了一单,订单中心抛了个“session过期”的异常,用户回到登录页重新输密码,结果密码是对的,登录死活不成功——因为订单中心的session存的是另一套用户标识,跟用户中心对不上。排查到半夜才发现是两套认证体系里同一个账号的用户ID字段类型都不一样,一个用字符串,一个用数字。

这种场景下,统一认证就不再是“优化体验”的锦上添花,而是系统层面的刚需。一套认证,全端通行,把用户、订单、营销、客服、支付各个系统的登录验证全部收口到一个中央认证服务上。用户只需要登录一次,后续访问任何子系统都被识别为同一个合法身份。同时各业务系统不再各自维护密码和登录态,只负责校验由中央服务签发的令牌是否有效。

统一认证方案的核心,说白了就是解决两个问题:第一,怎么让多个系统信任同一个登录结果;第二,怎么让用户在整个业务闭环里只登录一次。前者需要一套统一的令牌签发与校验机制,后者需要打通各端的登录态同步和跳转逻辑。方案做得好不好,直接决定用户在App、H5、小程序、管理后台之间的体验是否连贯,也决定了后续新增业务模块接入的速度。

这篇文章我会完整复盘一次电商项目统一认证方案的设计过程,从需求分析、技术选型、核心流程设计,到关键代码实现、多系统接入时的坑,以及线上问题排查经验。适合正在做电商中台、多端商城、包含多个业务模块的系统负责人、后端开发和架构师参考。

2. 整体方案设计与选择:为什么最终选了这套组合

2.1 技术选型的前后对比:纯Session方案和迫切的改革

在讲最终方案之前,先说说选型时对比过的几种路线。因为只有理解了为什么不用别的方案,你才知道这套方案到底解决了什么。

老一套是纯服务端Session方案。用户登录后,用户中心在服务端存一个Session,把SessionID通过Cookie写到浏览器,各子系统的请求通过网关时,网关拿着SessionID去用户中心查一下,查到就放行。这种方案最大的问题是Session存储和共享。网关每收到一个请求都要去中央服务查一次Redis,高并发下中央服务成了全系统的性能瓶颈;一旦Redis抖动,所有子系统全部登录失效,影响面是全站级别的。另外Session天然绑定浏览器Cookie,App端和H5端适配起来非常别扭。

还有一种折中是各子系统各自解析JWT,不做统一认证服务,登录逻辑仍然分散在各业务代码里。第一眼看起来“轻量”,实际上问题更大:每个系统自己对JWT的验证代码不一样,有的用对称算法,有的用非对称算法,密钥管理一地鸡毛。密钥一旦由一个子系统泄露,黑客就能伪造任意用户身份,整个电商平台的账号体系瞬间失守。这种方案在这个项目里属于坚决否掉的路线。

最终采用的是中央认证服务 + JWT无状态令牌 + Redis会话存储的组合方案。中心认证服务统一负责账号密码校验、验证码登录、第三方授权登录(如微信、支付宝)、令牌签发、令牌刷新和退出登录。签发的是JWT格式的访问令牌,业务系统只依赖网关完成令牌校验,不直接接触账号密码。网关层统一拦截请求,校验令牌合法性和有效期,再把解析后的用户身份信息放进请求头转发给下游服务。

这个组合的好处很明确:

  • 账号密码只在认证中心出现一次,业务系统不再接触敏感凭证,密码泄露面大幅缩小;
  • JWT无状态校验,网关拿到令牌后本地验签即可,不需要每次请求都回源认证中心查询,性能和可用性都稳得多;
  • Redis里只存会话的必要状态(比如当前会话版本号、刷新令牌、黑名单),认证中心重启也不影响已签发的令牌正常校验。

方案定下来之后,下一步就是拆清楚认证中心在整个电商系统里处于什么位置,以及它跟网关、业务系统、前端各端之间的关系。

2.2 整体架构分层:认证中心的位置和边界划分

整个电商项目是微服务结构,拆分成了用户中心、商品中心、订单中心、营销中心、支付服务、售后系统等模块。统一认证方案落地后,架构分层是这样的:

接入层:包括用户端App、H5商城、PC官网、商家后台、运营管理后台。各端不管什么形态,统一通过网关访问后端服务,登录时跳转到认证中心,拿到令牌后携带令牌访问业务接口。

网关层:负责统一入口和统一校验。所有业务请求先经过网关,网关从请求头里取令牌,验签、校验有效期、校验会话版本,合法则透传并附带解析出的用户信息;不合法则直接返回401,由前端跳转到认证中心重新登录。

认证中心层:独立部署的中央认证服务,负责登录入口、密码校验、短信验证码、第三方授权登录、令牌签发、令牌刷新、退出登录、黑名单管理。认证中心不参与任何具体电商业务流程,比如不会直接去查订单数据库或改商品库存。

基础支撑层:用户数据库(账号信息、密码散列、绑定手机号)、Redis集群(会话状态、刷新令牌、黑名单)、配置中心(JWT密钥、令牌有效期等参数)。

这里有一个容易被忽略的边界设计。认证中心虽然跟用户中心同属“用户域”,但在实际项目中我强烈建议两者拆分部署。用户中心管的是用户资料、地址、积分等业务数据,认证中心只管“这个请求是不是合法用户”。两者职责边界如果不清晰,到了后期,所有需要“查一下用户信息”的业务代码都可能顺手在认证中心里写接口,最终把它变成一个大杂烩服务。

边界划分原则就一条:跟“身份认证”无关的逻辑,一律不进认证中心。哪怕是查用户基础资料,也要由业务系统调用用户中心完成,而不是在认证中心里加一个“查询用户信息”的接口。

2.3 令牌体系设计:访问令牌与刷新令牌的双层结构

统一认证的令牌设计是整个方案里最重要的部分,也是最容易被拍脑袋的地方。许多项目直接用单个JWT,设置7天过期,过期了重新登录。这在电商场景里体验极差:用户7天没打开App,一打开发现要重新登录,购物车里东西还在,人却要被踢出去重验身份。

这里采用了双层令牌结构:

第一层是访问令牌(Access Token),JWT格式,有效期设置成2小时。客户端每次请求业务接口时携带,网关校验后放行。有效期短,核心目的是降低令牌泄露带来的风险窗口。就算令牌被人截获,2小时内失效,不至于造成长期盗用。

第二层是刷新令牌(Refresh Token),有效期设置成30天,存放在Redis里,并关联用户ID、设备标识、会话版本号。访问令牌过期后,客户端拿刷新令牌向认证中心换取新的访问令牌,用户无感知地完成续期。只要30天内用户有活跃行为,会话可以一直被续下去,不用重复登录。

为什么刷新令牌不也用JWT?最开始我设计时就想过直接签发两个JWT,访问令牌2小时,刷新令牌30天,业务系统统一验签。后来被否决了,原因非常现实:刷新令牌需要支持主动失效。用户在“账号安全中心”里点击“退出全部设备登录”,或者运营后台封禁一个账号,这个动作要能立刻让所有已签发的刷新令牌全部失效。如果刷新令牌是无状态的JWT,唯一的办法是所有业务系统都维护一个黑名单,复杂度直线上升。存Redis则很简单——退出登录时删除对应刷新令牌,封号时批量删除该用户所有刷新令牌,下一次刷新请求自然被拒。

双令牌结构对电商项目还有一个额外好处:它能区分“会话有效”和“操作有效”。访问令牌过期但刷新令牌还在,说明用户曾经登录过且没退出,前端可以静默续期;刷新令牌也没了,说明会话彻底结束,必须走完整登录流程。这种粒度在一些需要高安全性的操作(支付、改密、退款)上还可以叠加二次验证,令牌体系不用跟着改动。

2.4 为什么选JWT而不是自研令牌格式

既然已经用Redis存刷新令牌了,很多人会问:访问令牌为什么不干脆也搞一个随机字符串存Redis?反正网关校验时都是要去比对一次,JWT反而还要验签,多一层开销。

我测试过两种方式在同一个电商生产环境里的表现。百亿规模不敢说,百万级日活的商品查询量,接口每秒QPS大概两三千,纯随机字符串查Redis的开销和JWT本地验签的开销其实在一个量级。真正的决定性因素在另一个地方:业务系统需要“信任”用户信息。

电商场景里,营销系统要根据用户等级判断是否发放优惠券,订单系统要根据用户ID去落库,客服系统要根据用户昵称展示来电信息。如果访问令牌只是一个随机串,那么每个业务系统收到请求后都必须去认证中心换取用户详情,等于把一个高频查询压回认证中心,又一次把认证中心变成瓶颈。而JWT的payload里可以直接带用户ID、用户等级、角色、设备来源等基础信息,网关验签后把payload透传给下游,业务系统拿过来就能用,完全不回源。

当然,JWT里塞的内容要克制。如果全量身份信息都塞进去,令牌会变得很臃肿,每次请求都在网关和业务系统之间传递大包头,网络开销反而上升。我的习惯是payload里只放这几类:用户唯一标识、用户基础角色/等级、会话版本号、令牌类型、过期时间。详细资料需要时,业务系统自行调用用户中心,各取所需。

使用JWT还有一个很重要的安全优势:验签不依赖Redis的可用性。Redis在极端情况下抖动,刷新令牌可能暂时查不到,但已签发的访问令牌依然可以正常验签,用户当前进行的操作不受影响,顶多续期会短暂失败。对于电商大促场景,这种“尽量别让用户掉线”的能力非常重要。

3. 核心流程的设计与实现:登录、续期、退出的完整链路

3.1 登录流程:账号密码与验证码两条主线的统一处理

登录流程是认证中心里最核心的路径。电商项目里通常有两类主流登录方式:账号密码登录(第三方账号绑定后也算这一类)和短信验证码登录。两条路径最终都需要落到同一个动作——签发令牌。

先看账号密码登录的完整链路。用户在前端输入账号密码,前端把请求发给认证中心的登录接口。认证中心先查用户库,拿到用户记录后,用同样的散列算法对输入密码做一次计算,比对数据库中的密文是否一致。这里有个细节,散列计算不能只做一次,密码明文先加盐,再用迭代散列(比如PBKDF2或bcrypt类算法)算多次,防止彩虹表破解。

密码校验通过后,认证中心还需要做几项前置判断:账号是否被锁定(连续输错密码触发的临时锁定)、是否被禁用、是否要求强制改密。这些都通过后,才进入令牌签发阶段:生成访问令牌和刷新令牌,刷新令牌写入Redis并关联本次登录的设备标识。如果登录请求带了“记住我”之类的标记,刷新令牌的有效期可以相应延长;没带则按默认30天来。

短信验证码登录则是另一条链路。前端提交手机号和验证码,认证中心先校验验证码是否正确(校验过一次就作废,防止重复使用),然后查数据库:手机号是否存在用户记录?不存在则自动注册一个账号并绑定该手机号,存在则直接进入令牌签发。有一段时间我把“验证码校验”和“后续流程”做成了两个接口,后来发现频繁出现验证码被重复提交的问题——用户在前端点了两次登录按钮,两个请求都带着同一个验证码,第一个成功,第二个又拿这个码再进了一次流程,把刷新令牌覆盖了。最后改成验证码服务层加一次性消费标记,每个验证码只能被成功使用一次。这一点非常值得注意。

验证码登录里还有一个坑,也是电商场景特有的:换绑手机号。用户用A手机号注册,后来换绑成B手机号,这时候如果用B手机号登录,后端必须处理账号合并或提示用户资产迁移的问题。我在方案里预留了一个“账号关联”字段,换绑后旧手机号登录时会弹出确认框,确认后把旧账号下的订单和优惠券归并到新账号。前期没预留这个字段,后面想加就很痛苦。

3.2 令牌签发与刷新:关键参数的计算逻辑

令牌签发参数的选定,纯拍脑袋是行不通的。我这里把当时计算的逻辑完整公开,方便后人直接套用。

前提条件:日活约50万,用户平均在线时长约20分钟,平均每次会话内调用业务接口约80次,令牌续期最活跃的时段是早晨8点到10点。访问令牌有效期我们定了2小时,也就是7200秒。为什么不是30分钟?因为如果访问令牌30分钟过期,一次20分钟的活跃会话大概率还没结束令牌就过期了,客户端就需要在业务进行中发起刷新请求,不仅增加一次额外网络往返,还会有刷新令牌并发竞态的风险。2小时则基本能覆盖一次完整会话;一次会话内只需要刷新一到两次。

刷新令牌有效期我们定的是30天。计算依据来自用户活跃留存数据:电商App的一个普通用户的回访周期最长约两周,30天的有效期可以保证用户在常规回访周期内不需要重新输入密码,即便偶尔隔了三个星期才打开App,只要还在有效期内也会被静默续期。自动续期后,刷新令牌本身也要顺延有效期吗?这里我刻意不做顺延。刷新令牌固定30天失效,无论中途续期多少次,“会话生命周期最长30天”是一条硬边界。这样设计的好处是安全边界清晰,避免用户一次登录就永远不用再验身份;后端也可以通过配置文件调整,但思路保持“固定周期+滑动续期访问令牌”的组合。

刷新流程本身有两个容易被忽略的细节,我逐一说明。

第一个是刷新令牌与设备标识绑定。刷新令牌写入Redis时,key的构成是refresh_token:{用户ID}:{设备标识},value是令牌本身和签发时间。客户端在刷新令牌的请求里带上设备标识,认证中心校验设备匹配后才放行。这个设计可以拦截一种常见的攻击:攻击者拿到用户的刷新令牌,从自己的设备发起续期请求。如果不校验设备,令牌被偷走后等于账号永久失守;校验设备后,攻击者第一次续期就会因为设备不匹配而被拦截,同时触发风控告警。

第二个是刷新令牌的并发续期控制。上面提到过,访问令牌过期时,客户端如果同时发出多个请求,这些请求的访问令牌全部失效,于是同时发起多次刷新请求。如果不加控制,第一次刷新拿到新访问令牌,第二次刷新又基于旧的刷新令牌再签一个,签发了一堆访问令牌出来,旧会话也没有被正确失效,Redis里全是同一个人多个设备的多个刷新记录。这在业务上可能还没什么大问题,但极容易在排查问题时造成混淆。

解决方案是加一个短期互斥锁。认证中心在处理刷新请求时,先对“用户ID + 设备标识”维度加锁,锁有效期3秒,同一个用户同一台设备的并发刷新请求只有一个能真正执行,其他请求等待锁释放后直接复用第一次生成的访问令牌。实现上可以用Redis的SETNX命令配合过期时间来做,代码量不大,能把线上日志里“同一时间出现几十条刷新记录”的乱象直接按灭。

3.3 退出登录与多端会话管理

退出登录比大多数人想得复杂。最基础的退出是单端退出:用户在当前设备退出,删除当前设备的刷新令牌,同时把该设备的会话版本号递增。访问令牌无效化怎么做?由于JWT是无状态的,已签发的访问令牌在过期前依然可以验签通过。为了做到“立即失效”,我在设计里引入了会话版本号机制:

这里的处理方式是,Redis里存一份“当前会话版本号”或者干脆用刷新令牌的失效来间接生效。具体实现是网关校验时,JWT payload里带一个sessionVersion字段,网关从Redis查一下该用户当前会话版本号。如果JWT里的版本号低于Redis里的值,说明该会话已经被主动失效,网关直接拒绝。这个设计虽然让JWT校验从纯本地验签退化成了“先验签再查Redis”,但只针对“会话失效检查”,整体开销依然可控,换来了“点击退出立即生效”的确定性。业务系统也不用来回调整令牌过期时间。

如果用户主动退出了全局会话,我们就把该用户在所有设备上的刷新令牌全部删除,版本号统一递增。这意味着所有已签发的访问令牌都会立刻失效,用户不管在哪台设备上都会被强制登出。在这个电商项目里,全局退出对用户资产安全很重要,比如用户在共享设备上登录后忘记退出,从手机端发起“退出全部设备”可以让账号在其他设备全部失守,这是硬需求。

第三方授权登录(微信/支付宝)的会话处理稍有不同。用户用微信授权后,认证中心把微信的openid映射到站内用户ID,仍然走同样的令牌签发流程。这里有一个曾经踩过的深坑:微信授权回调里每次返回的openid是同一个,但unionid未必有,如果只拿openid当用户唯一键,而同一用户在不同App、不同开放平台下的openid不同,会导致一个用户映射出多个站内账号,用户在多个应用间跳转时出现“一会儿是这个账号,一会儿是那个账号”。后来在授权回调网上补了一层unionid优先的映射逻辑——有unionid就用unionid定位账号,无unionid再用openid。做第三方登录接入时一定要先想清楚账号映射主键,不然后面清洗数据痛不欲生。

3.4 网关层统一校验:业务系统彻底摆脱登录逻辑

统一认证有个重要的落地动作——把校验逻辑下沉到网关。业务系统完全不写“从请求头里找令牌再自己去验签”的代码,网关统一做好后把用户信息透传下去。

网关上做的是这些事:从请求头里取Authorization字段,剥掉Bearer前缀,拿到JWT后先验签(验证签名算法和签名值,防篡改),再校验过期时间,然后查Redis比对会话版本号。都通过后,把JWT payload里解析出的user_id、user_role、session_version塞进请求头,转发给下游业务服务。任何一个环节失败,网关直接返回401,并在响应体里给一个约定的错误码,前端根据错误码决定是跳登录页还是静默刷新。

这里有个要提醒的细节,就是透传用户信息时用的请求头字段不能有歧义。如果不同系统各自命名字段,比如有的用X-User-Id,有的用X-UserID,下游服务解析时要维护两套兼容,过三个月就乱成一锅粥。项目里统一约定:所有下游服务只认网关塞进来的X-Auth-User-Id和X-Auth-User-Role这两个头,且网关在转发前必须剥掉客户端传入的同名头,伪造身份请求就走不通了。

网关校验还有一个白名单配置的注意点。登录接口本身、以及某些公开接口(比如商品详情、首页推荐)是不需要登录就能访问的。这些路径不能在网关层写死成代码,我建议放到配置中心动态管理。电商大促时经常有“活动页临时免登录展示”的运营需求,如果白名单写死在代码里,每次都要改代码发版,运维同学会记恨你的。放到配置中心,运营提需求后一两分钟就能上配置,这个体验上的细节,被后来接手的人反复夸过。

4. 实战落地:认证中心核心代码与关键配置解析

4.1 认证中心登录接口的流程实现

说完了设计,我们把核心代码过一遍。认证中心登录接口整体的流程可以简化成下面这段伪码(为了便于理解,省略了具体框架的细节,只保留核心逻辑):

def login(request): account = request.params.get("account") password = request.params.get("password") device_id = request.params.get("device_id") user = user_service.get_by_account(account) if user is None: return failed("账号不存在") if user.is_locked(): return failed("账号已锁定,请稍后再试") if not verify_password(password, user.password_hash, user.password_salt): user_service.record_login_failure(user.id) return failed("密码错误") user_service.clear_login_failure(user.id) refresh_token = generate_refresh_token() refresh_key = build_refresh_key(user.id, device_id) redis_client.setex(refresh_key, REFRESH_TOKEN_EXPIRE_SECONDS, refresh_token) access_token = create_access_token(user_id=user.id, role=user.role, session_version=user.session_version) return success(access_token=access_token, refresh_token=refresh_token, expires_in=ACCESS_TOKEN_EXPIRE_SECONDS)

这段代码看着简单,但有几个值得深挖的细节。

一是密码校验必须放在账号状态判断之后。如果先校验密码再判断锁定状态,攻击者可以对一个锁定账号继续提交密码,反正密码校验会先执行,白白暴露了密码是否正确,顺带还能通过响应时间差做侧信道探测。正确姿势就是先查账号状态,明确能登录才验密码。

二是登录失败记录要记录用户ID而不仅是账号。记录账号会在日志里留下明文账号,而电商账号往往就是手机号,属于敏感信息;记录用户ID则日志脱敏更干净。缺点是如果攻击者拿的是不存在的账号,根本查不到用户ID,这种场景下按账号维度记录一次日志即可,不落库。

三是生成访问令牌时带上session_version,这个值直接从用户库里取当前值。一旦用户改了密码、被强制下线、全局退出,这个版本号就被后端递增,所有已签发的令牌全部作废。用户改密码时也建议触发一次版本号变更,相当于把旧令牌全部踢掉,避免“密码改了但旧会话还在”的尴尬。

4.2 网关校验过滤器:验签与版本检查

网关侧的核心是一个过滤器。伪码如下:

def gateway_filter(request): if request.path in whitelist_from_config_center(): return forward(request) token = extract_token(request) if token is None: return unauthorized("缺少访问令牌") try: claims = jwt_service.verify(token) # 验签+校验过期时间 except TokenExpired: return unauthorized("令牌过期") except TokenInvalid: return unauthorized("令牌无效") current_version = redis_client.get("session_version:" + claims.user_id) if current_version is not None and current_version != claims.session_version: return unauthorized("会话已失效") request.set_header("X-Auth-User-Id", claims.user_id) request.set_header("X-Auth-User-Role", claims.role) return forward(request)

这里有一个性能取舍值得展开。验签之后还要查一次Redis比对session_version,相当于把无状态JWT又拉回了“半有状态”。我当时的评估依据是这样的:网关层单次查询Redis的P99耗时在3毫秒以内,而JWT验签本身大概1毫秒,整体增加不超过4毫秒。对于一次正常业务请求来说,4毫秒的增加用户完全无感。换来的却是退出登录立即生效的能力,这个账是划算的。

如果对性能极端敏感,可以把session_version检查的结果做一层短时间缓存(比如3秒),网关在缓存有效期内直接放行。但这也意味着退出后最坏情况要3秒后才生效,取舍由业务方自行决定。大多数电商场景我建议不做这个缓存,毕竟安全性的优先级更高。

解密后的claims信息是否要传递给下游服务,也值得从安全角度再强调一次。请求到达网关时,客户端是可以自行伪造X-Auth-User-Id头的。如果网关不剥掉客户端传入的同名头,下游服务优先读取到了伪造头,就等于任意提权。代码里的set_header操作必须用覆盖写,而不是“为空才写”;最好在过滤器入口处统一把X-Auth-User-Id、X-Auth-User-Role这两个头先剥掉,再决定后续是放行还是拒绝。

4.3 刷新令牌的并发控制实现

刷新令牌的并发控制,我贴一段Redis锁的伪代码,这块在实际项目中非常有用:

def refresh_token(request): refresh_token = request.params.get("refresh_token") device_id = request.params.get("device_id") user_id = parse_user_id_from_refresh_token(refresh_token) lock_key = "refresh_lock:" + user_id + ":" + device_id locked = redis_client.set(lock_key, "1", nx=True, ex=3) if not locked: time.sleep(0.2) return retry_once(request) # 等锁释放后重新读取新令牌 stored = redis_client.get(build_refresh_key(user_id, device_id)) if stored is None or stored != refresh_token: return unauthorized("刷新令牌无效") new_access_token = create_access_token(user_id=user_id, role=user_role(user_id), session_version=user_session_version(user_id)) redis_client.expire(build_refresh_key(user_id, device_id), REFRESH_TOKEN_EXPIRE_SECONDS) return success(access_token=new_access_token, expires_in=ACCESS_TOKEN_EXPIRE_SECONDS)

注意一个细节:刷新访问令牌时不轮换刷新令牌本身。也就是说同一次会话里,客户端始终拿着同一个刷新令牌去续期。这样做省去了一种情况——多个请求并发刷新,第一个刷新接口刚把旧的刷新令牌作废、生成了一个新刷新令牌,第二个请求还带着旧的刷新令牌来刷,结果被判定为无效,导致用户被踢下线。最开始我确实做了“刷新令牌轮换”,后来在压测阶段频繁出现偶发掉线问题,排查到根因后决定改为不轮换方案。首次登录签发的刷新令牌在30天有效期内一直有效,过期或主动退出后才作废。

安全上降低一档换取的是稳定性的提升。毕竟刷新令牌在Redis里且与设备绑定,泄露路径本身就会触发风控告警,轮换带来的边际收益并不高。如果你做的是金融类项目,轮换可能是必选;但电商领域,稳定性优先,我建议不轮换。

4.4 短信验证码登录的一次性消费与防重放

短信验证码这块,完整逻辑再扩展一下。

def login_by_sms(request): phone = request.params.get("phone") code = request.params.get("code") scene = request.params.get("scene") # 如 login、bind_phone、reset_password if not verify_sms_code(phone, scene, code): return failed("验证码错误") invalidate_sms_code(phone, scene, code) user = user_service.get_by_phone(phone) if user is None: user = user_service.auto_register(phone=phone, source="sms_login") # ... 后续与密码登录一样,签发令牌

一次性消费的关键是invalidate_sms_code必须在verify之后立即执行。这里有个并发隐患:用户连续点击两次“登录”,前端发了两个请求,两个请求几乎同时到达后端,都调用verify_sms_code,此时验证码还没被消费,两个校验可能都通过,然后把同一个验证码用两次。多数后端架构下这种窗口极小,但电商大促时会真实发生。解决办法是给验证码加上“原子消费”操作——在Redis里用GETDEL或Lua脚本完成“读取并删除”的原子动作,或者数据库里用带条件更新的UPDATE把验证码状态改成已使用,条件是这个验证码尚未被使用。

短信平台的对接同样有讲究。验证码失效时间通常设5分钟;过期前3分钟内重复发送要加频率限制,同一手机号一天内的发送次数要有上限。当时遇到过被恶意刷短信的情况:攻击者用一个手机号不断请求发送验证码,如果后端没限制,一天的短信账单能烧掉上千块钱。我在方案里加了三个过滤:同一手机号60秒内只能发送一次,一天内最多5次,同一IP一天内最多20次。超过全部走风控拦截。

5. 多系统接入与单点登录的落地细节

5.1 多端登录跳转的统一规则

统一认证方案最终要面对多个入口场景:用户从App登录、从H5商城登录、从PC官网登录、从商家后台登录,甚至从运营后台登录。接入方多,如果跳转规则不统一,每个端各写一套,维护成本会很高。项目里我制定了一套约定,所有端遵循同一规则:

用户在未登录状态下访问任何受保护的资源,网关返回401并携带当前页面地址(callbackUrl)。前端拦截到401后,统一拼接认证中心的登录页地址,带上client_id、redirect_uri、state参数,跳转过去做登录。登录成功后,认证中心带一个临时授权码(authorization code)重定向回redirect_uri,前端或后端拿着授权码去认证中心换取令牌。

这里为什么不直接“登录成功后把令牌往回调地址带”?因为令牌太敏感,放进URL里容易写进日志、被浏览器历史记录、被Referrer头泄露。授权码有效期短(比如2分钟),只能换一次,就算泄露影响面也小得多。这个流程本质上是简单的OAuth2.0授权码模式,只是场景收敛到了自家多端,不用走完整的对外开放协议。

state参数务必带上并校验。它是防止CSRF的关键:攻击者诱导用户登录后,把一个伪造的重定向URL发过来,用户带了合法授权码,但没有正确的state参数,后端直接拒绝。当年接入一个第三方登录时,因为忽略state校验,在安全扫描里被打回来过一次,后面就再也没省过这一步。

5.2 商家后台与运营后台的特殊处理

电商项目里,商家后台和运营后台的认证要求比用户端高一个等级。用户端令牌可能泄露后,损失的是单个用户资产;商家后台的令牌泄露,可能导致商家信息批量泄露甚至资金操作风险。

我在方案里给商家后台和运营后台的令牌设置了更短的访问令牌有效期(比如30分钟),刷新令牌的有效期也压缩到7天,并且强制要求开启二次验证。登录时除了账号密码,还要通过短信或动态口令确认。登录后如果IP与常用城市不一致,风控系统自动触发一次短信确认,不确认则强制退出。

另一个区别是商家后台的角色模型跟用户端完全不同。用户端业务上不分角色,最多分个会员等级;商家后台要区分店主、店长、客服、财务、管理员等角色,不同角色能看到的菜单和数据范围不一样。令牌签发时角色权限点要细化,不能只给一个“商家用户”的角色字段。我当时在认证中心维护了一张“系统-角色-权限点”的映射表,认证中心只负责签发“该用户拥有哪些权限点”的声明,具体权限判断由各后台前端根据声明渲染菜单,后端接口依然要在网关层校验权限点,防止前端绕过。

运营后台的风险更高,建议单独加一层“操作审计”链路,登录、退出、改密等身份操作全部记录审计日志。遇到安全事件时,审计日志能回答“谁在什么时间从哪个IP做了什么操作”这个问题,这是事后追踪的数据基础。

5.3 接入新业务系统时的清单式流程

统一认证落地后,新增一个业务系统接入这件事应该变成“填表式操作”,而不是“研发攻坚”。我在交付文档里给新业务系统定义了一套标准接入清单,后续团队照着做就行。

接入清单大概是这样:

  1. 业务系统在网关注册服务路由,确定哪些路径是公开的、哪些需要登录;
  2. 确认业务系统读取用户身份的字段规范,统一使用X-Auth-User-Id和X-Auth-User-Role;
  3. 前端接入统一登录SDK,直接把“401响应后跳登录页”的逻辑封装好,不重复造轮子;
  4. 后端去掉自己解析令牌或Session的逻辑,遇到无用户身份直接抛401,让网关统一处理;
  5. 如有特殊角色需求,在权限映射表里走一次审批,由认证中心统一签发,不能在业务系统里自己改JWT;
  6. 联调时重点验证登录跳转、刷新续期、退出失效三个核心链路。

这套清单落地后,新系统从开发到上线接入认证中心,时间从以前的几天压到半天。接入过程不再需要认证中心的研发人员全程盯着,业务团队按清单自查一遍,有偏差再回来讨论,效率提升非常明显。

6. 实践中的暗坑与问题排查实录

6.1 典型的权限错误诊断与修复方法

这里归纳一下我在实际项目中遇到最多的几类问题,每一条都是踩过坑换来的,照着排查能省很多时间。

第一个是401错误“令牌无效”,但用户明明刚登录成功。排查路径是:先查认证中心日志里有没有签发记录,再查用户手里的令牌是不是签发时的那份,最后重点查网关验签用的密钥和认证中心签发用的密钥是否一致。有一个常见项目事故就是网关升级时配置文件里JWT密钥没同步,导致同一份令牌在签发端有效、验签端全挂。这个问题在项目初期反复出现,后来把密钥从配置中心单独管理,灰度变更前先做验签回归,才彻底止住。

第二个问题是刷新成功但业务请求仍401。通常发生在网关里做了“访问令牌校验失败就打回”的逻辑,而业务系统自己还有一套“token格式解析”逻辑,两边对令牌字段的理解不一致。比如网关认为用户ID字段叫user_id,业务系统解析时找的是userId,取不到结果就报空指针。排查时打开两个系统各自的日志,对照同一次请求的日志中解析出的用户ID是否一致,一般就能定位。

第三个问题是退出登录后原令牌仍能访问。根因几乎都在session_version检查那一步。排查时先确认退出接口是否把Redis里的session_version递增了,再确认网关检查用的是不是同一个Redis实例。如果认证中心和网关连了不同的Redis集群,版本号当然对不上,退出也就永远不会生效。架构初期就该避免这种情况,统一使用同一个会话状态存储。

6.2 生产环境中的踩坑实录:时间不同步与客户端缓存

时间类问题是最难排查的坑。

有一次线上集中出现“用户提交订单时提示令牌过期”的反馈,日志一看全是JWT的过期时间校验失败。用排除法查到根因是部分业务节点服务器时间比标准时间慢了几分钟。认证中心签发的令牌里exp是基于认证中心服务器时间计算的,业务系统所在节点校验过期时间时,用的却是自己的本地时间。时间偏慢的节点会把还没过期的令牌判定为过期,从而把用户挡在门外。

用过期的令牌去做续期操作时,续期接口本身也可能报校验失败。这就是典型的“为了修过期问题却产生了新的过期问题”。解决措施是把令牌过期时间的校验宽容度做成可配置的,允许前后几十秒的时钟偏移;同时生产环境所有节点强制启用NTP时间同步。后一步才是治本之道。

客户端缓存问题也很常见。前端把访问令牌存在本地存储里,H5页面升级时如果没有做缓存清理,旧页面里的旧令牌逻辑可能还在跑,新旧版本混用就会偶发“登录跳来跳去”的体验问题。我的建议是前端在认证SDK内部对令牌的过期时间自己做一次本地预判,令牌剩余有效期低于10分钟就提前触发静默刷新,不要等接到401才处理;版本升级时,清除旧的本地缓存。

6.3 高并发场景下的认证链路优化

电商大促时,认证中心的QPS会瞬间暴涨。大家通常以为瓶颈会在登录接口,实际上真正常见的瓶颈反而在刷新令牌接口和网关校验的Redis查询上。

刷新令牌接口在大促开场半小时会迎来峰值,因为一批用户早在几天前登录过,大促打开App时访问令牌刚好过期,扎堆续期。这时如果每个续期请求都经过业务代码,认证中心压力很大。我落地时的优化是:在认证中心前面加一层本地缓存,把用户ID+设备维度的新访问令牌缓存几秒,同一用户连续多次刷新直接命中缓存,不需要重复签发。上过这个大促之后,峰值时段的认证中心CPU使用率降了四成左右。

网关校验的Redis压力也在大促时被放大。我前面提到session_version检查需要查Redis,网关在2万QPS下会打出一个较大的Redis查询流量。优化做法是给session_version的查询结果做3秒级别的本地缓存。这个优化在稳定性和安全之间的取舍前面已经提过,3秒的延迟对绝大多数场景完全可接受,但对网关的Redis压力释放非常明显。

6.4 常见问题排查对照表

我整理了一份速查表,可以存下来直接当排查手册用。

现象可能原因排查方向
同一套令牌,有的节点放行有的节点拒绝各节点密钥配置不一致或时钟不同步检查配置中心密钥、NTP同步状态
退出登录后旧令牌仍能访问session_version未递增或Redis实例不一致查看退出接口日志、网关查的Redis地址
刷新令牌接口偶发失败,用户被踢下线刷新令牌被轮换或并发控制缺失检查刷新逻辑是否同时修改了刷新令牌本身
连续点击登录按钮,验证码被使用两次验证码校验与消费不是原子操作改为Redis原子读取删除或数据库条件更新
第三方登录出现账号错乱openid与unionid映射逻辑不正确统一账号定位主键为unionid,只保留openid为远端标识
网关透传用户身份字段可为任意值没有在网关层剥掉客户端同名头入口处强制删除X-Auth-User-Id和X-Auth-User-Role
用户改密码后旧会话还能操作改密码未递增session_version改密接口内联动递增该用户版本号
JWT验签通过但业务系统解析不到用户ID字段命名不一致统一约定claims内全部使用snake_case风格字段名

这张表不是我凭空总结的,每一条背后都是线上真实流量的反馈。统一认证系统的排查难度通常不在某一步逻辑里,而在多个系统字段、配置、时间维度上的不一致。排查顺序永远是从“两端是否配置一致”入手,再往下钻业务代码。

7. 方案迭代方向与个人经验总结

统一认证方案上线后的半年里,我持续盯着实际运行数据做了一些必要的修正。最值得在意的一个数据是登录成功率从改造前的82%提升到了94%。这12个百分点的提升主要来自两个点:访问令牌有效期从15分钟加到了2小时(降低了无效刷新频率),以及刷新令牌的并发控制让偶发掉线基本消失。原先那种“明明登录了却总在某些角落要求重新登录”的体验问题,在这个方案里被彻底按掉了。

还有个让我印象深刻的细节,是接入商家后台时,运营同事反馈“为什么你们认证中心一下子冒出这么多按钮”,因为商家后台的登录页复用了用户端的样式。这说明接入顺畅到让人没有感觉到“这是另一个系统”。统一认证做得好,应该做到让用户感知不到认证的存在。能做到这一步,方案就成了。

对于正在规划同类项目的人,我的建议是:先理清楚自己的多端规模和业务模块数量,再决定要不要上统一认证。如果只有一个单体应用、一个登录入口,直接做一套简单的Session方案就够用;但凡出现多端、多系统、多渠道,统一认证的收益就会随着复杂度上升而放大。技术选型上没有银弹,JWT也不一定比Session先进,关键在于是否匹配业务规模和团队维护能力。

最后再说一个容易被忽视的坑:密钥管理。所有方案设计里,密钥安全是最后一道防线。JWT密钥如果写死在代码里或者跟随配置文件进到Git仓库里,再好的架构设计也等于裸奔。项目上线前务必把密钥收口到配置中心,定期轮换,并建立密钥泄露的应急响应预案。技术细节可以慢慢优化,但密钥一旦出事,整个账号体系的安全都失去意义。

我实测下来,统一认证方案最动人的瞬间,就是新业务模块接入时,对方问“登录怎么弄”,你回答“不用弄,接入SDK就行了”。那一刻,整个方案的设计价值就体现出来了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进