第3篇:企业级CAS单点登录实战-技术架构设计方案

这篇作为整个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-gatewayservice-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-gatewayJwtAuthGatewayFilterFactory对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_userbase-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_roleumd_roleumd_role_privumd_privumd_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-ssosub取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 主要扩展两个点:CustomJdbcAuthenticationHelpercustomFields[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-umdservice-umd,网关只通过HTTP调token接口,不碰数据库。

为什么SLO回调失败不阻断

base-oauth2service-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-oauth2service-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-oauth2COM_AUDIT_TRAIL审计表和独立cas_audit.logservice-gateway 审查日志异步投递MQ,响应头回写X-trace-idservice-umdumd_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篇把单点登出闭环。

相关推荐
JoyT1 小时前
Agent 开源项目全景解析(上):LangGraph、Spring AI 与 Agent Runtime
后端
云技纵横1 小时前
线上接口突然超时,怎么判断卡在 Nginx、线程池、连接池还是 SQL?
后端·sql·mysql
Java内核笔记1 小时前
容错能力进入 spring-core:Spring Boot 4 原生重试机制全解析
java·后端
风卿1 小时前
知识库双路召回:BM25 关键词与语义向量 RRF 融合,附指标实测
后端
未秃头的程序猿1 小时前
虚拟线程上线一周后翻车了——pinning问题排查实录
java·后端·架构
用户6919026813391 小时前
Harness工程的概念,以及简单的代码示例
javascript·架构
阿拉斯攀登1 小时前
01-多端项目版本管控痛点:SaaS后端/安卓工控/小程序版本冲突问题解析
程序员
Dr.kangder1 小时前
嵌入式面试总结(一)——嵌入式系统实时性
面试·职场和发展·架构·嵌入式·虚拟化
AI_paid_community1 小时前
如何使用 Claude 在 AI 时代快速入局新的行业?(经验贴)
前端·javascript·后端