这篇作为整个SSO专栏的架构基线。后面几篇涉及的服务名、表名、接口路径、Redis Key以及JWT约定,都以本文为准。
下文认证中心 均指专栏服务base-oauth2(与第01、02篇中的「认证中心」同义)。
上一篇PRD把问题拆成了四件事:统一身份、统一登录、统一登出、可扩展接入。真正困难的地方,不是实现一次登录,而是让十年前的Web系统、现在的前后端分离应用,以及未来的新系统,共用一套身份体系。落到架构层面上,我们当时面临的不是「选CAS还是选OAuth2」这么简单,而是三类系统形态不一样,凭证形态也不一样,还要共用同一个认证中心。
为什么拆成四个服务
先把几个容易混在一起的职责分开:
CAS解决的是「你是谁」。 用户到认证中心登录,认证中心发TGT和ST,第三方Web应用拿ST向认证中心验票,在本地建Session。这条链路不需要业务JWT。
OAuth2解决的是「应用如何委托认证中心完成授权登录」。 在我们的场景里,前后端分离管理后台采用OAuth2授权码流程。管理后台没有服务端Session,浏览器只认JSON接口。网关作为OAuth2 Client,把用户带到认证中心,拿回授权码后再换访问令牌。这条链路拿到的是OAuth2访问令牌,而不是系统内部使用的业务JWT。
JWT解决的是「管理后台后续API怎么鉴权」。 网关换到OAuth2访问令牌之后,还要再调service-umd 换一张带用户id、工号、邮箱的业务JWT。这张令牌的签发、登出黑名单、issuer约定,都归service-umd管,不放在CAS里做。
因此我们没采用CAS统一签发JWT的方案。CAS只负责身份认证和票据生命周期,业务JWT由service-umd维护,登录体系和业务权限体系保持解耦。
用户主数据也拆成两个服务:
base-umd 负责员工主数据管理。CAS登录时,邮箱和钉钉扫码要先到这里解析出工号或邮箱,再交给JDBC认证。service-umd 负责管理端用户服务:查本地用户表、签JWT、写登出Redis。OAuth2 SSO成功后,网关调的是service-umd的token接口,这个接口设计上就是内部接口,只给网关调用,不应该直接暴露给外部系统。两个服务名字接近,一个偏主数据门面,一个偏管理端令牌和登出,职责不能混。
service-gateway只做入口:OAuth2登录、Bearer校验、路由转发。它不接用户库,也不签发JWT。很多团队习惯让网关直连用户表做权限判断,我们当时刻意没这么做,后面「关键取舍」一节会展开原因。
整体架构
部署4个微服务,逻辑上划分为三层:认证层、网关层、用户与身份服务层。

管理后台走OAuth2,不安装CAS Client;协作平台和知识库直连认证中心校验CAS服务票据。base-oauth2 仅在单点登出时回调service-umd ;业务JWT由service-gateway 向service-umd 换取。Redis分工:base-oauth2 存TGT/ST,service-umd 写登出标记,service-gateway读黑名单与SSO登出时间。
技术选型如下:
| 组件 | 选型 | 说明 |
|---|---|---|
| IdP | Apereo CAS 6.3 Overlay | CAS协议、OAuth2授权、SLO |
| 网关 | Spring Cloud Gateway WebFlux | OAuth2 Client、JWT校验 |
| 用户服务 | Spring Boot 2.x + MyBatis Plus | 用户主数据与JWT签发 |
| 缓存 | Redis | TGT/ST票据、登出标记、JWT黑名单 |
| 包名 | com.column.sso.* |
业务扩展类统一前缀 |
base-oauth2基于Apereo CAS Overlay构建,作为整个系统的统一身份提供方(IdP)。对传统Web应用,它提供CAS协议,通过ST完成认证;对前后端分离应用,则通过OAuth2授权码流程,为网关提供登录能力。TGT(Ticket Granting Ticket,票据授予票据)与ST(Service Ticket,服务票据)是CAS协议的两类核心票据。服务名里的oauth2取的是广义身份认证含义,并不是另起一套独立的OAuth2 Server。
四个服务各解决什么问题
base-oauth2是认证中心:登录页、多方式认证、TGT/ST、服务注册、全局登出编排都在这里。它不做业务JWT签发,避免认证中心和业务权限绑死。
service-gateway是管理后台的统一入口:OAuth2登录、授权码换访问令牌、再换业务JWT、后续Bearer校验和路由。它不直连用户库,登出校验只读Redis。
base-umd是用户主数据平台:员工表、组织信息、邮箱/钉钉扫码解析。CAS认证前需要它帮忙把各种登录入口归一到同一个人。
service-umd 是管理端用户服务:签JWT、处理SSO登出回调、维护JWT黑名单。SLO时base-oauth2 调它写Redis,service-gateway读Redis拦截旧令牌。
| 服务 | 核心能力 | 不做什么 |
|---|---|---|
| base-oauth2 | 多方式登录、JDBC认证、服务注册JSON、全局登出编排 | 不签发业务JWT |
| service-gateway | OAuth2登录入口、授权码换取访问令牌后再换取业务JWT、Bearer校验 | 不直连用户库 |
| base-umd | 用户主数据CRUD、邮箱/扫码查用户 | 不签发JWT |
| service-umd | 管理端JWT、登出回调、JWT黑名单管理 | 不承担CAS登录页 |
三条SSO链路
后面的实现篇会逐条展开,这里先把架构边界和流程定下来。时序图里的服务名、接口路径与后文API表一致。
链路A:前后端分离管理后台(OAuth2→JWT)

这里有一个容易误解的地方:网关OAuth2登录成功后,不会直接用CAS OAuth2模块签发的访问令牌当业务JWT,而是拿OAuth2身份里的loginName,再调service-umd 换一张issuer=umd-sso的管理端JWT。原因见下文「业务JWT为什么独立于CAS管理」。
前端只认service-gateway 的OAuth2入口(/api/service-gateway/admin/openapi/**),不安装CAS Client。JWT通过授权回调时的URL query parameter x-access-token下发,后续请求走Header Authorization: Bearer {token}。
链路B:协作平台A/知识库(CAS ST)

第三方产品是传统Web应用,本地Session是现成的,硬塞JWT反而要改更多。CAS ST验票后建Session,是这类系统改造成本最低的路径。
base-oauth2 侧用RegexRegisteredService JSON登记serviceId正则,配置logoutType: BACK_CHANNEL。应用侧过滤器与认证器改造见第07篇。
链路C:单点登出(SLO→Redis→网关拦截)
全局登出要同时处理两类凭证:第三方应用的本地Session,和管理后台手里还没过期的JWT。Session靠CAS Back-Channel SLO通知应用销毁;JWT靠service-umd 写Redis、service-gateway比对token签发时间与登出时间戳。
service-umd 写入service-gateway:user:sso:logout:{userId};service-gateway 的JwtAuthGatewayFilterFactory对issuer=umd-sso的token校验SSO登出时间。普通登出时service-umd 另写入service-gateway:user:logout:{token} JWT黑名单,由service-gateway读取校验。

数据设计
MySQL存员工主数据、RBAC和登录审计;Redis存TGT/ST、登出标记和JWT黑名单;接入应用在base-oauth2启动时从JSON加载RegisteredService。
| 存储 | 内容 | 读写服务 |
|---|---|---|
| MySQL | 员工主数据、RBAC、登录审计 | base-oauth2、base-umd、service-umd |
| Redis | TGT/ST(Apereo CAS Redis Ticket Registry)、SSO登出标记、JWT黑名单 | base-oauth2、service-gateway、service-umd |
| JSON文件 | 接入应用RegisteredService | base-oauth2启动加载 |
CAS产生的TGT和ST是临时票据,只在认证会话期间有效,没有长期业务价值,因此只保存在Redis,不进入MySQL。service-gateway不直连数据库,登出校验只读Redis。
用户主数据表(base-umd / service-umd 共用)
专栏表名 umd_user 。base-oauth2 JDBC认证、JWT签发、邮箱/工号/钉钉扫码归一均依赖此表。
| 字段 | 说明 | SSO用途 |
|---|---|---|
id |
主键 | JWT的sub;Redis登出key里的userId |
email |
企业邮箱 | 邮箱登录;CAS JDBC匹配字段 |
job_number |
工号 | 工号登录;优先作为CAS Principal |
mobile |
手机号 | 找回密码、Legacy登录 |
password |
密码密文 | JDBC密码校验(bcrypt,历史兼容md5) |
ding_userid |
钉钉用户ID | 钉钉扫码关联主用户 |
name |
姓名 | 展示 |
status |
启用状态 | CAS SQL:status=1 |
delete_flag |
软删除标记 | CAS SQL:delete_flag=0 |
resetting_password |
密码重置中标记 | 密码过期策略 |
password_updated_at |
密码更新时间 | 180天改密策略 |
SQL
WHERE (email = :username OR job_number = :username)
AND delete_flag = 0 AND status = 1
无login_name列。API参数loginName语义为email、job_number或mobile;CAS Principal优先用job_number,无工号时回退email(与第02篇PRD一致)。
钉钉扫码辅助表(base-umd)
专栏表名 umd_ding_user 。扫码授权码经unionid/ding_userid定位员工,再关联umd_user。
| 字段 | 说明 |
|---|---|
ding_userid |
钉钉用户ID |
unionid |
钉钉unionid |
job_number |
工号 |
email |
邮箱 |
mobile |
手机号 |
delete_flag |
软删除 |
管理后台授权表(base-umd / service-umd)
umd_user_role、umd_role、umd_role_priv、umd_priv、umd_module五张表管RBAC,不参与CAS登录认证。第04篇展开用户表;RBAC后续按需引用。
base-oauth2辅助表
COM_AUDIT_TRAIL记登录审计,也是JDBC登录节流的数据源。RegisteredService通过services/*.json按环境管理(第05篇给样例),不走数据库注册。
Redis Key约定
| Key | 用途 |
|---|---|
service-gateway:user:sso:logout:{userId} |
SSO全局登出Unix时间戳 |
service-gateway:user:logout:{token} |
单JWT黑名单 |
service-gateway:user:logout:at:{userId} |
用户级登出时间 |
JWT约定(service-umd签发)
issuer为umd-sso;sub取userId;claims包含id、job_number、email。授权回调时通过URL query parameter x-access-token下发;后续请求通过Header Authorization: Bearer {token}携带。
API边界(专栏命名)
base-oauth2→base-umd
| 方法 | 路径 | 用途 |
|---|---|---|
| GET | /center/base-umd/user/detail/email/{email} |
邮箱登录解析用户 |
| GET | /center/base-umd/user/detail/ding/scan/{code} |
企业扫码解析用户 |
service-gateway→service-umd
| 方法 | 路径 | 用途 |
|---|---|---|
| GET | /api/service-umd/admin/user/token?loginName= |
OAuth2成功后换取业务JWT |
base-oauth2→service-umd(SLO回调)
| 方法 | 路径 | 用途 |
|---|---|---|
| POST | /api/service-umd/admin/user/logout/email/{emailOrJobNumber} |
全局登出写入Redis |
service-gateway对外
| 前缀 | 用途 |
|---|---|
/api/service-gateway/admin/openapi/** |
OAuth2 callback与白名单 |
| 其他受保护路由 | JwtAuth过滤器 |
服务注册策略(base-oauth2)
同一套CAS中心通过不同的RegisteredService类型,同时服务OAuth2网关链路和CAS ST第三方链路:
| 应用类型 | RegisteredService类型 | 关键字段 |
|---|---|---|
| 管理后台OAuth2 | OAuthRegisteredService | clientId、redirectUri环境regex |
| 协作平台A/知识库 | RegexRegisteredService | serviceId正则、logoutType=BACK_CHANNEL |
JSON按环境拆分(dev/test/pre/prod),evaluationOrder控制匹配优先级。
代码层面的几个扩展点
后面几篇会对着源码改,这里先把关键类串起来,方便对照时序图看。
base-oauth2 主要扩展两个点:CustomJdbcAuthenticationHelper按customFields[loginType]分流邮箱、工号、钉钉扫码,邮箱和扫码登录时调base-umd 解析身份;ExternalLogoutNotifier(对应源码里DefaultLogoutManager的扩展)在SLO时POST service-umd写登出Redis,回调失败只打warn,不阻断Back-Channel SLO。
service-gateway 侧:GatewaySecurityConfig配OAuth2 Login链;OAuth2AuthenticationSuccessHandler在OAuth2成功后调service-umd 换JWT并302回前端;JwtAuthGatewayFilterFactory做Bearer校验,对issuer=umd-sso的token额外查Redis登出时间和JWT黑名单。
service-umd 侧:AdminUserController暴露token和logout接口;JwtTokenProvider.createAdminToken写入用户claims和issuer。
base-umd 侧:UserQueryController提供邮箱和钉钉扫码查询。
设计中的几个关键取舍
为什么CAS和OAuth2并存而不是二选一
第02篇PRD里两类系统的凭证形态就不一样:管理后台要JWT,第三方Web应用要Session。OAuth2授权码适合SPA走网关;CAS ST适合已有CAS Client的Jira/Wiki。共用同一个base-oauth2 ,只是RegisteredService类型不同:管理后台登记OAuthRegisteredService,协作平台和知识库登记RegexRegisteredService。运维上多维护一种协议配置,但应用侧改造成本低很多。
业务JWT为什么独立于CAS管理
CAS的OAuth2模块确实可以直接为Client签发JWT Access Token(RegisteredService里配jwtAccessToken: true即可)。但我们管理后台需要的JWT带有业务claims(userId、工号、邮箱),还要跟Legacy登录、主动登出、SSO全局登出共用同一套黑名单和issuer约定。这些逻辑已经在service-umd里跑通了,再塞进CAS Overlay,认证中心和业务权限会强耦合,后续改密钥、改claims、改登出策略都要动CAS发布节奏。拆出来以后,CAS只管「登录成功」,JWT只管「后续API访问」,边界清楚。
为什么网关不直连用户库
service-gateway 的pom里没有JDBC/MyBatis依赖。鉴权靠JWT签名校验加Redis登出标记;角色和API权限从Redis缓存读,不每次查库。认证入口应该保持轻量,只负责身份验证和路由,不应该逐渐变成「大杂烩」,既做OAuth2 Client,又管用户表,又管权限SQL。用户数据的写操作集中在base-umd 和service-umd,网关只通过HTTP调token接口,不碰数据库。
为什么SLO回调失败不阻断
base-oauth2 调service-umd 登出接口超时或失败,只记warn,Back-Channel SLO继续通知第三方应用销毁Session。如果因为service-umd短暂不可用就把SLO整段掐掉,Jira/Wiki的本地Session可能清不掉,用户以为已经退出,第三方系统却还能访问。JWT侧的登出标记可以稍后补上;Session侧必须优先保证CAS标准SLO走完。
为什么Redis故障采用fail-open
service-gateway查JWT黑名单和SSO登出时间时,Redis异常会记warn并当作「未登出」处理,请求继续放行。限流模块也是类似取向:Redis不可用时优先保证流量可用,而不是因为缓存故障把整个认证链路打死。这是有意的取舍:生产环境里Redis短暂抖动比「全员无法访问后台」代价小。当然fail-open意味着Redis故障期间登出拦截会失效,必须配监控告警,不能裸奔。
非功能性设计
高可用与水平扩展
base-oauth2的TGT/ST存在Redis(Apereo CAS Redis Ticket Registry),多实例共享同一票据存储,前面挂负载均衡就行,不需要Sticky Session。
service-gateway 鉴权在Filter链完成,会话状态由JWT和Redis承载,本身无状态;下游路由通过服务发现(如lb://service-umd)分担流量。
TGT默认7天无操作过期(time-to-kill-in-seconds: 604800);OAuth access token按接入方JSON配置TTL;JWT另有主动登出与SSO全局登出失效机制。
网关侧配Hystrix隔离和fallback;Feign调base-umd 失败走FallbackFactory。base-oauth2 调service-umd登出超时仅告警,不阻断Back-Channel SLO。
安全防护
登录口有多层防护:base-oauth2 侧JDBC登录节流(查COM_AUDIT_TRAIL,按IP+用户名统计失败频率)和图形验证码;service-umd 侧Redis错误计数(密码连错5次锁5分钟,验证码连错3次锁1分钟);service-gateway 侧API令牌桶限流(Method+Path规则,Redis Lua计数)。单层都不够,几层叠在一起才扛得住撞库。验证码白名单(captcha-ignores)仅限非生产测试账号。
新用户统一用bcrypt存密码;历史账号因为迁移成本,暂时通过MultiAlgorithmPasswordEncoder兼容md5,用户下一次修改密码时再升级。密码策略要求8到16位复杂度,180天未改密强制改密。JWT失效靠JWT黑名单、用户级登出时间和SSO全局登出时间三层。传输层Nginx TLS终结,用X-Forwarded-*还原客户端IP。第三方应用的Session Cookie设HttpOnly,降低XSS窃取Session风险。网关还有ValidateAccess按角色/API权限做二次拦截。不同OAuth2客户端可以通过JSON配置独立的JWT签名密钥。
异常降级
认证失败时,账号/密码错误统一文案;验证码、禁用、节流blocked各有独立提示。base-umd解析失败则中断认证链,不拿空用户名去走JDBC认证。
全局登出时service-umd 回调失败仅记warn,Back-Channel SLO继续。管理端JWT缺失、过期或已登出返回401;issuer=umd-sso的token额外校验Redis登出时间戳与JWT黑名单。
限流与登出查询在Redis异常时默认放行,须配监控告警。MQ消费与事件监听失败记日志/trace,不影响主登录链路。
扩展性
新系统接入:新增services/*.json,不改Java代码。新登录方式:CAS登录表单通过customFields[loginType]区分邮箱/工号/钉钉扫码,在CustomJdbcAuthenticationHelper加分支即可。节流阈值、钉钉appId、验证码白名单等经配置中心+@RefreshScope在线调整。权限二级缓存:网关Redis共享+本地EhCache;base-umd权限变更经Fanout MQ刷新各节点。
可观测性
base-oauth2 写COM_AUDIT_TRAIL审计表和独立cas_audit.log;service-gateway 审查日志异步投递MQ,响应头回写X-trace-id;service-umd 用umd_sys_log记操作日志,对齐traceId。
风险矩阵
| 风险 | 对策 |
|---|---|
| OAuth2与CAS双协议配置漂移 | JSON服务注册纳入配置评审 |
| 登录口撞库攻击 | JDBC节流+验证码+Redis计数+API限流(多层协同,单层不足以覆盖) |
| 全局登出漏拦截 | issuer=umd-sso强制SSO Redis校验 |
| Redis故障 | fail-open+监控告警(优先可用性,非fail-close) |
| 第三方改造遗漏 | PRD清单+第07篇Checklist |
| 密码双轨迁移 | bcrypt/md5兼容+180天改密 |
小结
四个服务、三条链路、一套Redis Key约定,就是这篇要定下来的东西。后面第04篇从base-oauth2 的认证Handler入手,把多方式登录和JDBC认证先跑通;第06篇接service-gateway的OAuth2;第07篇接CAS Client;第08篇把单点登出闭环。