ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

第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登出时间。

技术选型如下:

组件选型说明
IdPApereo CAS 6.3 OverlayCAS协议、OAuth2授权、SLO
网关Spring Cloud Gateway WebFluxOAuth2 Client、JWT校验
用户服务Spring Boot 2.x + MyBatis Plus用户主数据与JWT签发
缓存RedisTGT/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-gatewayOAuth2登录入口、授权码换取访问令牌后再换取业务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 parameterx-access-token下发,后续请求走HeaderAuthorization: Bearer {token}

链路B:协作平台A/知识库(CAS ST)

第三方产品是传统Web应用,本地Session是现成的,硬塞JWT反而要改更多。CAS ST验票后建Session,是这类系统改造成本最低的路径。

base-oauth2侧用RegexRegisteredServiceJSON登记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
RedisTGT/ST(Apereo CAS Redis Ticket Registry)、SSO登出标记、JWT黑名单base-oauth2、service-gateway、service-umd
JSON文件接入应用RegisteredServicebase-oauth2启动加载

CAS产生的TGT和ST是临时票据,只在认证会话期间有效,没有长期业务价值,因此只保存在Redis,不进入MySQL。service-gateway不直连数据库,登出校验只读Redis。

用户主数据表(base-umd / service-umd 共用)

专栏表名umd_userbase-oauth2JDBC认证、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天改密策略
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 parameterx-access-token下发;后续请求通过HeaderAuthorization: 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类型关键字段
管理后台OAuth2OAuthRegisteredServiceclientId、redirectUri环境regex
协作平台A/知识库RegexRegisteredServiceserviceId正则、logoutType=BACK_CHANNEL

JSON按环境拆分(dev/test/pre/prod),evaluationOrder控制匹配优先级。

代码层面的几个扩展点

后面几篇会对着源码改,这里先把关键类串起来,方便对照时序图看。

base-oauth2主要扩展两个点:CustomJdbcAuthenticationHelpercustomFields[loginType]分流邮箱、工号、钉钉扫码,邮箱和扫码登录时调base-umd解析身份;ExternalLogoutNotifier(对应源码里DefaultLogoutManager的扩展)在SLO时POSTservice-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篇把单点登出闭环。

返回列表