☰
CAS单点登录动态添加Service:数据库存储与自定义Registry实现
2026/10/15 3:43:52 网站建设 项目流程

1. 从硬编码到动态注册:为什么需要动态管理services

做过CAS单点登录集成的人,大概率都经历过这样一个阶段:所有接入的应用服务(Service)都写在application.properties或者services目录下的JSON文件里,每新增一个接入方,就得改配置文件、重启CAS Server。刚开始接入的应用少,三五个服务,手动改改还能忍。等到公司内部系统越来越多,十几个甚至几十个业务系统都要走统一登录,每次加一个服务就重启一次认证中心,运维同事的脸色就不好看了。

这个问题的本质在于:CAS Server默认的Service注册机制是静态的。它启动时从配置文件或JSON目录加载所有Service定义到内存中,运行期间不会主动感知外部变化。这种设计在服务数量少、变更频率低的时候没问题,但一旦进入多团队协作、快速迭代的场景,静态配置就成了瓶颈。

动态添加services要解决的核心诉求有三个:第一,不重启——新增接入方时CAS Server保持运行,不影响已登录用户的会话;第二,持久化——动态添加的Service定义要能存下来,重启后不丢失;第三,可管理——能通过接口或管理界面对Service进行增删改查,而不是靠人去改文件。

我见过不少团队在这个环节走了弯路。有的直接改内存里的ServiceRegistry,结果重启后全没了;有的每次新增服务就滚动重启CAS集群,虽然比全量重启好一点,但依然影响用户体验。真正靠谱的方案,是把Service定义存到数据库或Redis里,通过自定义的ServiceRegistry实现动态加载,再暴露一套管理接口来做CRUD。

下面这张表对比了几种常见方案的优劣,你可以根据自己团队的实际情况来选:

方案持久化是否需重启实现复杂度适用场景
改配置文件是是低服务极少、变更不频繁
改JSON目录+定时扫描是否中中小规模、能接受分钟级延迟
数据库存储+自定义Registry是否中高多团队、频繁变更
纯内存动态注册否否低临时测试、不推荐生产

选哪种方案,取决于你的服务规模、变更频率和团队运维能力。接下来我会重点讲数据库存储+自定义Registry这条路线,因为它在生产环境中最稳,也是我实际用得最多的方案。

2. CAS Service注册机制的底层逻辑拆解

2.1 ServiceRegistry与ServicesManager的关系

要理解动态添加,先得搞清楚CAS内部是怎么管理Service的。CAS的核心接口是ServicesManager,它负责协调所有Service的注册和查询。而真正存储Service定义的是ServiceRegistry接口,它提供了save、delete、findServiceBy、getAllServices等方法。

默认情况下,CAS使用InMemoryServiceRegistry,顾名思义,所有Service都存在内存里。启动时,ServiceRegistryInitializer会从配置的serviceRegistry资源路径加载JSON文件,解析成RegisteredService对象后塞进内存。这就是为什么你改了JSON文件必须重启才生效——因为加载动作只在启动时执行一次。

ServicesManager则是在ServiceRegistry之上做了一层封装,它内部维护了一个ServiceRegistry实例,同时提供了getRegisteredService等便捷方法。当你调用servicesManager.save(service)时,最终会委托给底层的ServiceRegistry.save()。

理解这层关系很关键:动态添加的本质,就是替换掉默认的InMemoryServiceRegistry,换成一个能从外部数据源实时读取的ServiceRegistry实现。

2.2 RegisteredService的属性结构

每个Service在CAS里对应一个RegisteredService对象,它有一堆属性需要你关注:

  • serviceId:服务的唯一标识,通常是一个URL模式,比如https://app.example.com/**。CAS用Ant风格或正则来匹配请求的service参数。
  • name:服务名称,用于管理界面展示。
  • description:描述信息。
  • id:CAS内部生成的唯一ID,动态添加时通常由数据库自增或UUID生成。
  • evaluationOrder:匹配优先级,数字越小优先级越高。当多个Service的serviceId都能匹配同一个请求时,CAS按这个顺序选第一个匹配的。
  • logoutUrl:单点登出时通知该服务的地址。
  • attributeReleasePolicy:属性释放策略,控制哪些用户属性可以传给该服务。
  • accessStrategy:访问策略,比如是否允许匿名访问、是否限制IP段等。

动态添加时,最容易踩坑的是serviceId的匹配规则和evaluationOrder的设置。我见过有人把serviceId写成精确URL,结果用户带了个查询参数就匹配不上了;也见过多个Service的evaluationOrder都是默认值0,导致CAS随机选一个,行为不可预测。

2.3 默认加载流程的局限性

默认的加载流程是这样的:CAS启动 →ServiceRegistryInitializer读取配置 → 解析JSON → 构建RegisteredService列表 → 存入InMemoryServiceRegistry→ 对外提供服务。

这个流程有三个硬伤:第一,加载时机固定在启动阶段,运行期间无法触发重新加载;第二,存储介质是内存,进程重启数据就没了;第三,没有对外暴露管理接口,只能靠改文件。

要突破这些限制,我们需要做两件事:一是实现一个自定义的ServiceRegistry,让它从数据库读取数据;二是提供一个管理接口,让外部系统能往数据库里写数据。这样CAS每次查询Service时都去数据库读,自然就能感知到变化。

3. 基于数据库的自定义ServiceRegistry实现

3.1 数据库表结构设计

先把存储层搭好。我一般会建两张表:一张存Service的基本信息,一张存属性释放策略的配置。如果属性策略不复杂,也可以合并成一张表,用JSON字段存策略配置。

CREATE TABLE cas_service_registry ( id BIGINT PRIMARY KEY AUTO_INCREMENT, service_id VARCHAR(512) NOT NULL, name VARCHAR(255) NOT NULL, description VARCHAR(1024), evaluation_order INT DEFAULT 0, logout_url VARCHAR(512), attribute_release_policy TEXT, access_strategy TEXT, enabled TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_service_id (service_id) );

这里有几个设计决策值得说明。service_id加了唯一索引,防止重复注册同一个服务;enabled字段用于软删除,删除服务时只把标志位置0,不物理删除,方便追溯;attribute_release_policy和access_strategy用TEXT存JSON字符串,灵活度高,不用为每种策略建单独的列。

注意:service_id的长度要留够。有些团队用正则表达式做匹配,正则写长了很容易超过255字符,我建议直接给到512。

3.2 自定义ServiceRegistry的核心方法

实现ServiceRegistry接口,重点覆盖这几个方法:

public class DatabaseServiceRegistry implements ServiceRegistry<RegisteredService> { private final JdbcTemplate jdbcTemplate; private final RegisteredServiceMapper mapper; @Override public RegisteredService save(RegisteredService service) { if (service.getId() == -1) { // 新增 jdbcTemplate.update( "INSERT INTO cas_service_registry (service_id, name, description, evaluation_order, logout_url, attribute_release_policy, access_strategy, enabled) VALUES (?,?,?,?,?,?,?,?)", service.getServiceId(), service.getName(), service.getDescription(), service.getEvaluationOrder(), service.getLogoutUrl(), serializePolicy(service.getAttributeReleasePolicy()), serializeStrategy(service.getAccessStrategy()), 1 ); } else { // 更新 jdbcTemplate.update( "UPDATE cas_service_registry SET service_id=?, name=?, description=?, evaluation_order=?, logout_url=?, attribute_release_policy=?, access_strategy=? WHERE id=?", service.getServiceId(), service.getName(), service.getDescription(), service.getEvaluationOrder(), service.getLogoutUrl(), serializePolicy(service.getAttributeReleasePolicy()), serializeStrategy(service.getAccessStrategy()), service.getId() ); } return service; } @Override public RegisteredService findServiceBy(Service service) { String serviceId = service.getId(); List<Map<String, Object>> rows = jdbcTemplate.queryForList( "SELECT * FROM cas_service_registry WHERE enabled=1" ); for (Map<String, Object> row : rows) { String pattern = (String) row.get("service_id"); if (matches(pattern, serviceId)) { return mapper.mapRow(row); } } return null; } @Override public Collection<RegisteredService> getAllServices() { List<Map<String, Object>> rows = jdbcTemplate.queryForList( "SELECT * FROM cas_service_registry WHERE enabled=1 ORDER BY evaluation_order ASC" ); return rows.stream().map(mapper::mapRow).collect(Collectors.toList()); } @Override public void delete(RegisteredService service) { jdbcTemplate.update( "UPDATE cas_service_registry SET enabled=0 WHERE id=?", service.getId() ); } }

findServiceBy是调用最频繁的方法,每次有登录请求进来,CAS都会调它来匹配Service。这里有个性能考量:如果Service数量多,每次都全表扫描再逐个匹配,效率会很低。优化思路有两个:一是加缓存,把Service列表缓存在内存里,定时刷新或通过消息通知刷新;二是在数据库层面做初步过滤,比如先用LIKE缩小范围,再在内存里做精确匹配。

我一般会加一层本地缓存,用ConcurrentHashMap存Service列表,设置一个合理的过期时间(比如30秒)。这样既保证了动态性,又避免了每次请求都查库。如果对实时性要求极高,可以引入消息队列,管理端写入后发一条消息,CAS收到消息后主动刷新缓存。

3.3 匹配逻辑的坑:Ant模式与正则的取舍

CAS支持两种匹配方式:Ant风格路径模式和正则表达式。Ant模式写起来简单,比如https://*.example.com/**,但表达能力有限;正则表达式灵活,但容易写错。

private boolean matches(String pattern, String serviceId) { if (pattern.startsWith("^")) { // 正则模式 return Pattern.matches(pattern, serviceId); } else { // Ant模式 AntPathMatcher matcher = new AntPathMatcher(); return matcher.match(pattern, serviceId); } }

这里有个经验:能用Ant模式就别用正则。正则的回溯问题在极端情况下会导致性能骤降,而且团队里不是每个人都看得懂复杂的正则。如果确实需要正则,一定要加超时保护,避免恶意构造的serviceId导致ReDoS攻击。

另外,evaluationOrder的设置要拉开差距。我通常按10、20、30这样的间隔来分配,方便后续在中间插入新的Service。如果都用默认值0,CAS的匹配顺序就不确定了。

4. 暴露管理接口:让运维和开发都能自助操作

4.1 REST接口设计

有了数据库存储和自定义Registry,接下来要解决的是"怎么往里写数据"。最直接的方式是暴露一组REST接口,让运维平台或内部管理系统调用。

@RestController @RequestMapping("/api/cas/services") public class ServiceManagementController { @Autowired private ServicesManager servicesManager; @PostMapping public ResponseEntity<?> create(@RequestBody ServiceDefinition def) { RegisteredService service = buildService(def); servicesManager.save(service); return ResponseEntity.ok(service.getId()); } @PutMapping("/{id}") public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ServiceDefinition def) { RegisteredService existing = servicesManager.findServiceBy(id); if (existing == null) { return ResponseEntity.notFound().build(); } updateService(existing, def); servicesManager.save(existing); return ResponseEntity.ok().build(); } @DeleteMapping("/{id}") public ResponseEntity<?> delete(@PathVariable Long id) { RegisteredService service = servicesManager.findServiceBy(id); if (service != null) { servicesManager.delete(service); } return ResponseEntity.ok().build(); } @GetMapping public ResponseEntity<?> list() { return ResponseEntity.ok(servicesManager.getAllServices()); } }

接口设计上,我建议遵循RESTful风格,用POST创建、PUT更新、DELETE删除、GET查询。返回体里带上Service的完整信息,方便调用方确认。

注意:这组接口必须加权限控制。CAS的管理接口如果裸奔,任何人都能注册一个恶意Service来窃取用户信息。我一般会要求请求头里带一个内部Token,或者直接把这组接口部署在内网,通过网关做IP白名单。

4.2 参数校验与默认值处理

动态添加Service时,调用方往往只传最少的必填字段,剩下的靠默认值兜底。这里要做几层校验:

第一层,serviceId不能为空,且格式要合法。如果是Ant模式,至少要包含一个*或**,否则就是精确匹配,容易出问题。如果是正则,要能编译通过。

第二层,name不能为空,方便后续管理界面展示。

第三层,evaluationOrder如果没传,给一个默认值,比如100。但要注意,如果数据库里已经有相同evaluationOrder的Service,要么报错,要么自动调整。

private RegisteredService buildService(ServiceDefinition def) { RegexRegisteredService service = new RegexRegisteredService(); service.setServiceId(def.getServiceId()); service.setName(def.getName()); service.setDescription(def.getDescription()); service.setEvaluationOrder(def.getEvaluationOrder() != null ? def.getEvaluationOrder() : 100); service.setLogoutUrl(def.getLogoutUrl()); service.setAttributeReleasePolicy(parsePolicy(def.getAttributeReleasePolicy())); service.setAccessStrategy(parseStrategy(def.getAccessStrategy())); return service; }

属性释放策略这块,最常用的是ReturnAllAttributeReleasePolicy(返回所有属性)和ReturnAllowedAttributeReleasePolicy(只返回白名单里的属性)。从安全角度考虑,我强烈建议默认用白名单模式,只释放业务系统真正需要的属性,比如用户名、邮箱、部门,而不是把用户的所有信息都吐出去。

4.3 与现有静态配置的兼容

很多团队不是从零开始,而是已经有一批Service写在JSON文件里了。动态方案上线后,不能让这些老Service失效。我的做法是:在自定义Registry的getAllServices里,把数据库里的Service和JSON文件里的Service合并返回。

@Override public Collection<RegisteredService> getAllServices() { List<RegisteredService> all = new ArrayList<>(); // 先加载静态配置的 all.addAll(staticRegistry.getAllServices()); // 再加载数据库里的 all.addAll(loadFromDatabase()); return all; }

合并时要注意去重。如果同一个serviceId既在JSON里又在数据库里,以数据库为准,因为数据库代表的是最新的动态配置。这样迁移过程可以平滑进行:先把新服务往数据库里加,老服务继续留在JSON里,等时机成熟再逐步迁移。

5. 缓存策略与性能优化:别让动态变成慢的借口

5.1 本地缓存与刷新机制

动态Registry最大的性能隐患就是每次查询都打数据库。CAS的登录流程里,findServiceBy会被调用多次,如果每次都查库,QPS一高数据库就扛不住了。

我的方案是加一层本地缓存,用ConcurrentHashMap或者Caffeine都行。缓存key用serviceId,value是匹配到的RegisteredService。但这里有个问题:serviceId是模式匹配,不是精确匹配,所以不能简单用请求的service参数做key。

更实际的做法是缓存整个Service列表,然后在内存里做匹配。列表不大(通常几百条),内存匹配很快。缓存刷新策略有两种:定时刷新和事件驱动刷新。

public class CachedServiceRegistry implements ServiceRegistry<RegisteredService> { private volatile List<RegisteredService> cache = Collections.emptyList(); private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); @PostConstruct public void init() { refresh(); scheduler.scheduleAtFixedRate(this::refresh, 30, 30, TimeUnit.SECONDS); } private void refresh() { List<RegisteredService> fresh = loadFromDatabase(); this.cache = fresh; } @Override public RegisteredService findServiceBy(Service service) { for (RegisteredService rs : cache) { if (matches(rs.getServiceId(), service.getId())) { return rs; } } return null; } }

30秒的刷新间隔对大多数场景够用了。如果管理端要求"添加后立即生效",可以在管理接口里加一个刷新触发点,写入数据库后直接调refresh()。

5.2 数据库连接与查询优化

如果Service数量确实很多(比如上千条),全表加载到内存也有压力。这时候可以考虑分页加载或者按前缀过滤。但说实话,CAS的Service数量通常不会大到这个程度,真到了上千个接入方,架构上应该考虑拆分CAS集群了。

数据库查询本身也要优化。enabled=1这个条件要加索引,evaluation_order排序也要走索引。我一般会建一个联合索引:

CREATE INDEX idx_enabled_order ON cas_service_registry (enabled, evaluation_order);

另外,attribute_release_policy和access_strategy这两个TEXT字段在列表查询时其实用不到,可以延迟加载。但CAS的RegisteredService对象需要完整信息才能工作,所以这个优化空间不大。如果确实有性能问题,可以考虑把策略配置拆到单独的表,用的时候再关联查询。

5.3 集群环境下的缓存一致性

CAS通常不是单机部署,而是集群。每个节点都有自己的本地缓存,管理端更新了数据库,怎么让所有节点都刷新?

最简单的方案是缩短刷新间隔,比如改成10秒。但这样数据库压力会大一些。更好的方案是引入Redis的发布订阅:管理端写入后往Redis发一条消息,所有CAS节点订阅这个频道,收到消息就刷新本地缓存。

@RedisListener("cas:service:refresh") public void onRefresh(String message) { refresh(); }

这样既保证了实时性,又避免了频繁轮询数据库。如果团队没有Redis,用数据库的定时轮询也能凑合,只是延迟大一点。

6. 踩坑实录:动态注册过程中遇到的典型问题

6.1 serviceId匹配不上的三种常见原因

第一种,大小写问题。URL的host部分是不区分大小写的,但path部分是区分的。CAS默认的匹配是大小写敏感的,如果用户访问的URL和注册的serviceId大小写不一致,就会匹配失败。解决办法是在匹配前统一转小写,或者用不区分大小写的匹配器。

第二种,末尾斜杠。https://app.example.com和https://app.example.com/在CAS看来是两个不同的serviceId。注册的时候最好用通配符https://app.example.com/**,这样带不带斜杠都能匹配上。

第三种,端口号。如果应用通过非标准端口访问,比如https://app.example.com:8443,注册时漏了端口号就匹配不上。这个坑在测试环境特别常见,因为测试环境经常用非标准端口。

排查这类问题,最直接的办法是打开CAS的DEBUG日志,把org.apereo.cas.services包的日志级别调到DEBUG,能看到每次匹配的详细过程。

6.2 属性释放策略配置错误导致信息泄露

我见过一个案例:某团队为了图省事,把所有Service的属性释放策略都设成了ReturnAllAttributeReleasePolicy,结果用户的所有属性(包括手机号、身份证号)都被传给了每一个接入的应用。这属于严重的安全问题。

正确的做法是最小权限原则:每个Service只释放它真正需要的属性。比如一个只做身份认证的应用,只需要用户名;一个OA系统可能需要用户名、邮箱、部门;一个财务系统可能需要额外的角色信息。

配置的时候,用ReturnAllowedAttributeReleasePolicy,把允许释放的属性列出来:

{ "attributeReleasePolicy": { "@class": "org.apereo.cas.services.ReturnAllowedAttributeReleasePolicy", "allowedAttributes": ["username", "email", "department"] } }

动态添加时,这个策略要作为必填项,不能给默认的"全部释放"。

6.3 并发写入导致的重复注册

管理接口如果被并发调用,可能同时插入两条相同serviceId的记录。虽然数据库有唯一索引兜底,但第二个请求会抛异常,返回给调用方的错误信息不友好。

解决办法是在应用层加锁,或者用数据库的INSERT ... ON DUPLICATE KEY UPDATE语法做幂等处理。我倾向于后者,因为不依赖应用层的锁,集群环境下也安全。

INSERT INTO cas_service_registry (service_id, name, ...) VALUES (?, ?, ...) ON DUPLICATE KEY UPDATE name=VALUES(name), update_time=NOW();

这样重复提交同一个Service时,不会报错,而是更新已有记录。

6.4 删除服务后的会话处理

删除一个Service后,已经登录的用户怎么办?CAS默认不会主动踢掉这些用户的会话。如果这个Service被删了是因为安全原因(比如应用下线了),那已登录的会话可能还存在风险。

我的做法是:删除Service时,同时调用CAS的TicketRegistry,把这个Service相关的所有Ticket清理掉。这样用户下次访问时就需要重新认证,而重新认证时因为Service已经不存在,会被拒绝。

public void delete(RegisteredService service) { // 软删除 jdbcTemplate.update("UPDATE cas_service_registry SET enabled=0 WHERE id=?", service.getId()); // 清理相关Ticket ticketRegistry.deleteTicketsByService(service); }

这个操作要谨慎,因为会影响在线用户。建议在管理界面上加一个确认提示,让操作者知道会踢掉多少用户。

7. 动态注册之后:还能怎么玩

动态添加Service只是第一步。在此基础上,还可以做更多有意思的事情。

比如服务分组。给Service加一个group字段,按业务线或团队分组,管理界面上可以按组筛选,批量操作。再比如审批流。新增Service时不是直接生效,而是进入待审批状态,由管理员审核通过后才真正注册。这在多团队共用CAS的场景下很有必要,防止有人随意注册服务。

还有自动发现。如果你们的应用部署在容器平台上,可以监听容器事件,新应用上线时自动往CAS注册Service,下线时自动注销。这样连管理接口都不用调了,完全自动化。

不过这些扩展都要建立在一个前提上:基础的动态注册机制足够稳定。先把数据库存储、自定义Registry、管理接口这三块做扎实,再考虑上层的高级功能。我见过太多团队一上来就搞自动化,结果基础不牢,出了问题排查半天。

最后分享一个实用技巧:在管理接口里加一个/validate端点,传入serviceId后返回匹配到的Service详情和匹配过程。这样排查匹配问题时,不用去翻日志,直接调接口就能看到结果。这个端点在实际运维中救过我很多次。

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

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

立即咨询