diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 5e06537..837ba58 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,4 +1,11 @@ -name: deploy-cassso-service +name: build-push + +# CAS 接入 projman 部署平台后:本 workflow 只负责 构建镜像 + 推 Nexus + 通知 projman 建档, +# 部署由运维在 projman「后端发布」tab 手动选 tag 执行(App.deployMode=manual)。 +# 触发:push 到 main / 手动 Run workflow +# 依赖仓库 Secrets:NEXUS_USER / NEXUS_PASSWORD(docker-hosted 推送)/ DEPLOY_TOKEN(webhook 鉴权)/ WEBHOOK_URL +# 镜像名与 projman 派生规则一致:应用名 cassso → 镜像 cassso-service(NexusClient.imageRepo = 应用名小写 + "-service") +# 注意:nexus:8083 为 HTTP 仓库,宿主 daemon 需配置 insecure-registries 才能推送(projman 一直在拉,通常已配) on: push: @@ -6,40 +13,35 @@ on: workflow_dispatch: jobs: - build-deploy: + build-push: runs-on: ubuntu-latest - # docker:24 自带 docker CLI;宿主 docker.sock 默认挂入 job 容器。 + # docker:24 自带 docker CLI;宿主 docker.sock 默认挂入 job 容器 container: image: docker:24 steps: - - name: Install git (alpine) - run: apk add --no-cache git + - name: Install git / curl (alpine) + run: apk add --no-cache git curl - name: Checkout (manual, internal Gitea) - # cas.war (174MB) 已作为普通 blob 入库(服务端未启用 LFS) + # cas.war (约 170MB) 已作为普通 blob 入库(服务端未启用 LFS) run: | git clone --depth 1 --branch main http://192.168.10.160:3000/${GITHUB_REPOSITORY}.git /tmp/src + COMMIT_MSG=$(git -C /tmp/src log -1 --pretty=%s | tr '\n' ' ' | sed 's/["\\]/ /g' | cut -c1-200) + echo "COMMIT_MSG=${COMMIT_MSG}" >> "$GITHUB_ENV" cp -a /tmp/src/. . - rm -rf .git .gitea - rm -rf /tmp/src + rm -rf .git .gitea /tmp/src - - name: Build image (copies docker/cas.war) on host daemon - # 经由挂载的 docker.sock 在宿主守护进程上构建,镜像直接落在宿主本地 - run: docker build -f docker/Dockerfile -t cassso-service:latest . + - name: Build image (copies docker/cas.war, host daemon) + run: docker build -f docker/Dockerfile -t nexus:8083/docker-hosted/cassso-service:${GITHUB_SHA:0:8} . - - name: Deploy on host (infra-infra-net, CAS 端口 8443) - # 镜像已在宿主本地,直接运行,无需推送到 nexus(nexus docker 仓库为 HTTP,需 daemon insecure-registries 才能推送) - # Nacos 环境变量:NacosEnvironmentPostProcessor 启动阶段据此从 Nacos 原生拉取 - # common.yaml (DEFAULT_GROUP) + cas.yaml (CAS group),namespace=prod。 - # 不传 NACOS_NAMESPACE 会默认连 dev(dev 命名空间无配置),登录必失败——必须显式传 prod。 + - name: Push Nexus docker-hosted run: | - docker rm -f cas-sso 2>/dev/null || true - docker run -d --name cas-sso --network infra-infra-net -p 8443:8443 --restart unless-stopped \ - -e NACOS_ADDR=nacos:8848 \ - -e NACOS_NAMESPACE=prod \ - -e NACOS_USER=yh-reader \ - -e NACOS_PASS=123456 \ - cassso-service:latest + echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u "${{ secrets.NEXUS_USER }}" --password-stdin + docker push nexus:8083/docker-hosted/cassso-service:${GITHUB_SHA:0:8} - - name: Done - run: 'echo "CasSsoService deployed -> https://192.168.10.160:8443/login"' + - name: Notify projman (release pending, manual deploy) + run: | + curl -sS -X POST "${{ secrets.WEBHOOK_URL }}" \ + -H "Content-Type: application/json" \ + -H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \ + -d "{\"app\":\"cassso\",\"tag\":\"${GITHUB_SHA:0:8}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\"}" diff --git a/docker/Dockerfile b/docker/Dockerfile index 3e7bdf1..2e91ff6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -17,8 +17,8 @@ COPY docker/run-cas.sh /app/run-cas.sh COPY etc/cas/config/log4j2.xml /etc/cas/config/log4j2.xml RUN chmod +x /app/run-cas.sh -# 镜像内 cas.properties 仅保留本地无关配置(HTTPS/端口/日志/管理页白名单); -# 业务配置(cas.server.name / JDBC / Redis 等)由 NacosEnvironmentPostProcessor 在启动阶段 -# 从 Nacos 原生拉取(common.yaml + cas.yaml,namespace=NACOS_NAMESPACE),镜像保持环境无关。 +# 镜像内 cas.properties 仅保留环境无关配置(HTTPS/端口/日志/管理页白名单); +# 业务配置(cas.server.name / cas.authn.jdbc / Redis / custom.login.* 等)来自 projman +# 配置管理挂载的 /app/config/application.yml(Boot 1.5 自动加载 file:./config/ 且优先于 classpath),镜像保持环境无关。 EXPOSE 8443 ENTRYPOINT ["/app/run-cas.sh"] diff --git a/docker/cas.properties b/docker/cas.properties index ad9149e..844a242 100644 --- a/docker/cas.properties +++ b/docker/cas.properties @@ -1,7 +1,6 @@ # CAS 烤进镜像的本地兜底配置(与运行环境无关、不含任何凭据) -# 业务配置由 Nacos 原生注入(NacosEnvironmentPostProcessor): -# common.yaml(DEFAULT_GROUP) + cas.yaml(CAS),namespace=NACOS_NAMESPACE -# -> cas.server.name / JDBC 连接 / Redis 等全部来自 Nacos,本文件不写。 +# 业务配置来自 projman 配置管理挂载的 /app/config/application.yml: +# -> cas.server.name / cas.authn.jdbc / Redis / custom.login.* 等全部由挂载文件提供,本文件不写。 # 仅保留本地必需的:HTTPS 密钥库、端口、日志、管理页白名单。 server.port: 8443 server.ssl.enabled: true diff --git a/docker/cas.war b/docker/cas.war index f3155b0..37c758e 100644 Binary files a/docker/cas.war and b/docker/cas.war differ diff --git a/docker/docker-compose.cas.yml b/docker/docker-compose.cas.yml index 6021647..a39075c 100644 --- a/docker/docker-compose.cas.yml +++ b/docker/docker-compose.cas.yml @@ -1,3 +1,8 @@ +# ⚠️ 已废弃(2026-08-26):CAS 已接入 projman 部署平台,容器编排由 projman 生成 +# (容器名 cassso-service,/data/configs/cassso/docker-compose.yml),不再使用本文件。 +# Nacos 配置注入已移除(NacosEnvironmentPostProcessor + spring.factories 已删除), +# 业务配置由 projman 配置管理挂载 /app/config/application.yml 提供。本文件仅保留作回滚参考。 + # CAS 服务端编排:仅 cas 应用。 # MySQL / Redis / RabbitMQ 均为 infra 公共实例(mysql8 等),不在本编排内自建。 # CAS 启动后由 NacosEnvironmentPostProcessor 从 Nacos 原生拉取: diff --git a/docker/run-cas.sh b/docker/run-cas.sh index f6f9098..922a4fb 100644 --- a/docker/run-cas.sh +++ b/docker/run-cas.sh @@ -1,12 +1,10 @@ #!/bin/sh -# CAS 启动入口(原生 Nacos 配置注入版) -# 业务配置改由 org.apereo.cas.web.custom.config.nacos.NacosEnvironmentPostProcessor -# 在 Spring Boot 环境准备阶段从 Nacos 原生拉取: -# - 公共基础配置 common.yaml (namespace=NACOS_NAMESPACE, group=DEFAULT_GROUP) -# - CAS 自身配置 cas.yaml (namespace=NACOS_NAMESPACE, group=CAS) -# 不再需要本脚本写 /etc/cas/config/cas.properties,CAS 镜像保持环境无关。 +# CAS 启动入口(接入 projman 部署平台版) +# 已移除 Nacos 配置注入:NacosEnvironmentPostProcessor + spring.factories 已删除(2026-08-26), +# 容器启动不再访问 Nacos。业务配置来源(优先级高→低): +# - projman 配置管理挂载的 /app/config/application.yml(容器 WORKDIR /app 下 ./config/,Spring Boot 默认搜索路径) +# - /etc/cas/config/cas.properties(镜像内置:仅 TLS/端口/日志,环境无关) +# - WAR classpath application.yml(TGC/webflow 密钥等兜底默认值) set -e -echo "[run-cas] Nacos 原生配置注入:NACOS_ADDR=${NACOS_ADDR:-} NACOS_NAMESPACE=${NACOS_NAMESPACE:-} NACOS_USER=${NACOS_USER:-}" - exec java -jar /app/cas.war diff --git a/src/main/java/org/apereo/cas/web/custom/config/nacos/NacosEnvironmentPostProcessor.java b/src/main/java/org/apereo/cas/web/custom/config/nacos/NacosEnvironmentPostProcessor.java deleted file mode 100644 index fc81160..0000000 --- a/src/main/java/org/apereo/cas/web/custom/config/nacos/NacosEnvironmentPostProcessor.java +++ /dev/null @@ -1,387 +0,0 @@ -package org.apereo.cas.web.custom.config.nacos; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.env.EnvironmentPostProcessor; -import org.springframework.core.Ordered; -import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.MapPropertySource; -import org.springframework.core.env.MutablePropertySources; -import org.springframework.core.env.PropertySource; - -import org.yaml.snakeyaml.Yaml; - -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * 在 Spring Boot 环境准备阶段从 Nacos 拉取配置并注入 Environment, - * 使 CAS 5.3 能像 Spring Cloud 应用一样读到 Nacos 上的 common.yaml / cas.yaml。 - * - * 背景:CAS 5.3 无原生 nacos config 模块,且不自带 nacos-client(其 Jackson 2.8/2.9 与 - * nacos-client 期望的版本冲突)。这里直接用 Nacos 开放 HTTP API(与 run-cas.sh 同一套接口) - * 拉取配置,用 SnakeYAML(CAS webapp 已内置)解析,零额外依赖、无版本冲突风险。 - * - * 注入优先级:插在 classpath application.yml 之前,因此 Nacos 配置会覆盖 application.yml 中的 - * 同名键;但不覆盖系统属性/环境变量(更安全的兜底顺序)。 - * 若 Nacos 不可达或配置缺失,fail-fast=false,直接跳过 -> 回退 application.yml(本地离线开发友好)。 - * - * 环境隔离沿用 namespace 模型:NACOS_NAMESPACE 环境变量决定连哪个命名空间(dev/prod/空=public), - * 与 UserBase 共用同一套隔离逻辑。 - */ -public class NacosEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { - - private static final String PROPERTY_SOURCE_NAME = "nacosConfig"; - - private static final Logger log = LoggerFactory.getLogger(NacosEnvironmentPostProcessor.class); - - @Override - public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { - // 总开关:默认开启;设为 false 可完全跳过(纯离线本地开发) - if (!Boolean.parseBoolean(firstNonNull(environment.getProperty("nacos.config.enabled"), "true"))) { - log.info("[nacos] nacos.config.enabled=false,跳过 Nacos 配置加载"); - return; - } - - // 地址 / 命名空间:优先读字面环境变量 NACOS_ADDR / NACOS_NAMESPACE(与 UserBase bootstrap、 - // run-cas.sh 同一套约定),再回退到 Spring Cloud 风格键,最后给默认值。 - // 本地默认连 dev 命名空间(零配置),生产显式传 NACOS_NAMESPACE=prod。 - String addr = firstNonNull( - environment.getProperty("NACOS_ADDR"), - environment.getProperty("spring.cloud.nacos.config.server-addr"), - environment.getProperty("nacos.config.addr"), - "nacos.yunhaixingtong.com:8848"); - String namespace = firstNonNull( - environment.getProperty("NACOS_NAMESPACE"), - environment.getProperty("spring.cloud.nacos.config.namespace"), - environment.getProperty("nacos.config.namespace"), - "dev"); - String username = firstNonNull( - environment.getProperty("NACOS_USER"), - environment.getProperty("NACOS_USERNAME"), - environment.getProperty("spring.cloud.nacos.config.username"), - environment.getProperty("nacos.config.username"), - "yh-reader"); - String password = firstNonNull( - environment.getProperty("NACOS_PASS"), - environment.getProperty("NACOS_PASSWORD"), - environment.getProperty("spring.cloud.nacos.config.password"), - environment.getProperty("nacos.config.password"), - "123456"); - - String commonDataId = firstNonNull(environment.getProperty("nacos.config.common-data-id"), "common.yaml"); - String commonGroup = firstNonNull(environment.getProperty("nacos.config.common-group"), "DEFAULT_GROUP"); - String casDataId = firstNonNull(environment.getProperty("nacos.config.cas-data-id"), "cas.yaml"); - String casGroup = firstNonNull(environment.getProperty("nacos.config.cas-group"), "CAS"); - - try { - String token = login(addr, username, password); - // 把命名空间名字(dev/prod)解析成 Nacos 的 namespaceId: - // 配置接口 tenant 参数只认 ID 不认名字,直接传 "prod" 会查不到配置。 - String resolvedNs = resolveNamespace(addr, namespace, token); - - Map common = fetchYaml(addr, commonDataId, commonGroup, resolvedNs, token); - Map cas = fetchYaml(addr, casDataId, casGroup, resolvedNs, token); - - Map merged = buildCasProperties(common, cas); - if (merged.isEmpty()) { - log.warn("[nacos] 未从 Nacos 解析出任何有效配置,回退 application.yml"); - return; - } - - MapPropertySource source = new MapPropertySource(PROPERTY_SOURCE_NAME, merged); - MutablePropertySources sources = environment.getPropertySources(); - // 插在 application.yml 之前,使其覆盖 application.yml,但低于系统属性/环境变量 - String appName = findApplicationConfigName(sources); - if (appName != null) { - sources.addBefore(appName, source); - } else { - sources.addFirst(source); - } - log.info("[nacos] 已从 Nacos 注入 {} 个配置项 (namespace='{}' -> '{}', addr={})", merged.size(), namespace, resolvedNs, addr); - } catch (Exception e) { - log.warn("[nacos] 加载 Nacos 配置失败,回退 application.yml:{}", e.getMessage()); - } - } - - /** - * 从 common.yaml + cas.yaml 构造最终生效的 CAS 属性。 - * 关键点:common.yaml 是跨服务共享的「Spring Boot 标准」配置(spring.datasource.* / spring.redis.*), - * 而 CAS 的 JDBC 认证只认 cas.authn.jdbc.query[0].*,因此这里把 spring.datasource.* 翻译成 - * cas.authn.jdbc.query[0].url,逻辑与 run-cas.sh 一致。 - */ - private Map buildCasProperties(Map common, Map cas) { - Map out = new LinkedHashMap<>(); - - // 1) cas.yaml 透传(cas.* 自有配置先放入,后续 JDBC 连接键再覆盖) - if (cas != null) { - out.putAll(cas); - } - - // 2) 从 common.yaml 抽取数据源(兼容 spring.datasource.* 与 mysql.* 两种命名) - String dsHost = firstNonNull(str(common, "spring.datasource.host"), str(common, "mysql.host")); - String dsPort = firstNonNull(str(common, "spring.datasource.port"), str(common, "mysql.port"), "3306"); - String dsUser = firstNonNull(str(common, "spring.datasource.username"), str(common, "mysql.user")); - String dsPass = firstNonNull(str(common, "spring.datasource.password"), str(common, "mysql.password")); - String dsParams = firstNonNull( - str(common, "spring.datasource.params"), - str(common, "mysql.params"), - "useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"); - - // 3) 库名来自 cas.yaml(cas.authn.jdbc.query[0].database),缺省 bsch_userbase - String dbName = firstNonNull(str(cas, "cas.authn.jdbc.query[0].database"), "bsch_userbase"); - - // 4) 拼 JDBC URL 并覆盖连接键(与 run-cas.sh 逻辑一致) - if (dsHost != null && !dsHost.isEmpty()) { - String jdbcUrl = "jdbc:mysql://" + dsHost + ":" + dsPort + "/" + dbName + "?" + dsParams; - out.put("cas.authn.jdbc.query[0].url", jdbcUrl); - if (dsUser != null) { - out.put("cas.authn.jdbc.query[0].user", dsUser); - } - if (dsPass != null) { - out.put("cas.authn.jdbc.query[0].password", dsPass); - } - out.put("cas.authn.jdbc.query[0].driverClass", "com.mysql.jdbc.Driver"); - } - - // 5) 透传 common.yaml 里的 redis / rabbitmq 连接(自定义 /api/login 依赖 Redis 存验证码/失败计数) - if (common != null) { - for (Map.Entry e : common.entrySet()) { - String k = e.getKey(); - if (k.startsWith("spring.redis.") || k.startsWith("spring.rabbitmq.")) { - out.put(k, e.getValue()); - } - } - } - return out; - } - - private String findApplicationConfigName(MutablePropertySources sources) { - for (PropertySource ps : sources) { - if (ps.getName() != null && ps.getName().contains("applicationConfig")) { - return ps.getName(); - } - } - return null; - } - - /** - * 把命名空间名字(dev/prod/public)解析成 Nacos 的 namespaceId。 - * Nacos 配置接口 tenant 参数只认 namespaceId,不认命名空间显示名,因此必须转换。 - * 若无法解析(API 不可达/无权限),原样返回输入,调用方把它当作 ID 兜底。 - */ - @SuppressWarnings("unchecked") - private String resolveNamespace(String addr, String namespace, String token) { - if (namespace == null || namespace.isEmpty()) { - return namespace; // public 的 namespaceId 即空串 - } - try { - String url = "http://" + addr + "/nacos/v1/console/namespaces"; - if (token != null && !token.isEmpty()) { - url += "?accessToken=" + enc(token); - } - String body = httpGet(url); - if (body == null || body.trim().isEmpty()) { - return namespace; - } - // Nacos 返回的是 JSON,而 JSON 是 YAML 子集,可直接用内置 SnakeYAML 解析(零额外依赖) - Yaml yaml = new Yaml(); - Object loaded = yaml.load(body); - if (!(loaded instanceof Map)) { - return namespace; - } - Object data = ((Map) loaded).get("data"); - if (!(data instanceof List)) { - return namespace; - } - for (Object item : (List) data) { - if (!(item instanceof Map)) { - continue; - } - Map m = (Map) item; - String id = str(m, "namespace"); - String showName = str(m, "namespaceShowName"); - if (namespace.equals(showName) || namespace.equals(id)) { - log.info("[nacos] 命名空间 '{}' 解析为 namespaceId='{}'", namespace, id); - return id; - } - } - } catch (Exception e) { - log.warn("[nacos] 解析命名空间失败,按原值 '{}' 作为 namespaceId 兜底:{}", namespace, e.getMessage()); - } - return namespace; - } - - /** 拉取并解析单个 Nacos 配置(YAML),失败返回 null */ - private Map fetchYaml(String addr, String dataId, String group, String namespace, String token) { - String content = getConfig(addr, dataId, group, namespace, token); - if (content == null || content.trim().isEmpty()) { - return null; - } - try { - Yaml yaml = new Yaml(); - Object loaded = yaml.load(content); - Map flat = new LinkedHashMap<>(); - flatten("", loaded, flat); - return flat; - } catch (Exception e) { - log.warn("[nacos] 解析 {} 失败:{}", dataId, e.getMessage()); - return null; - } - } - - /** 递归展平为点号键(列表用 [i] 下标,与 CAS RelaxedBinding 兼容,如 cas.authn.jdbc.query[0].url) */ - @SuppressWarnings("unchecked") - private void flatten(String prefix, Object value, Map out) { - if (value instanceof Map) { - for (Map.Entry e : ((Map) value).entrySet()) { - String key = String.valueOf(e.getKey()); - String np = prefix.isEmpty() ? key : prefix + "." + key; - flatten(np, e.getValue(), out); - } - } else if (value instanceof List) { - List list = (List) value; - for (int i = 0; i < list.size(); i++) { - flatten(prefix + "[" + i + "]", list.get(i), out); - } - } else if (value != null) { - out.put(prefix, value); - } - } - - /** HTTP GET 拉取 Nacos 配置;鉴权开启时带 token,失败自动不带 token 重试 */ - private String getConfig(String addr, String dataId, String group, String namespace, String token) { - String base = "http://" + addr + "/nacos/v1/cs/configs"; - String qs = "dataId=" + enc(dataId) + "&group=" + enc(group) + "&tenant=" + enc(namespace); - if (token != null && !token.isEmpty()) { - String body = httpGet(base + "?" + qs + "&accessToken=" + enc(token)); - if (body != null && !body.trim().isEmpty() && !body.contains("\"error\"")) { - return body; - } - } - return httpGet(base + "?" + qs); - } - - /** 登录 Nacos 换取 accessToken;鉴权关闭时返回 null(调用方自动降级) */ - private String login(String addr, String username, String password) { - try { - String url = "http://" + addr + "/nacos/v1/auth/login?username=" + enc(username) + "&password=" + enc(password); - String resp = httpPost(url, ""); - if (resp == null) { - return null; - } - int i = resp.indexOf("\"accessToken\""); - if (i >= 0) { - int s = resp.indexOf('"', i + 14); - int e = resp.indexOf('"', s + 1); - if (s >= 0 && e > s) { - return resp.substring(s + 1, e); - } - } - } catch (Exception e) { - log.warn("[nacos] 登录 Nacos 失败(可能是鉴权关闭):{}", e.getMessage()); - } - return null; - } - - private String httpGet(String urlStr) { - HttpURLConnection conn = null; - try { - URL url = new URL(urlStr); - conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod("GET"); - conn.setConnectTimeout(10000); - conn.setReadTimeout(10000); - if (conn.getResponseCode() == 200) { - return readBody(conn); - } - } catch (Exception e) { - log.warn("[nacos] GET {} 失败:{}", urlStr, e.getMessage()); - } finally { - if (conn != null) { - conn.disconnect(); - } - } - return null; - } - - private String httpPost(String urlStr, String body) { - HttpURLConnection conn = null; - try { - URL url = new URL(urlStr); - conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod("POST"); - conn.setConnectTimeout(10000); - conn.setReadTimeout(10000); - conn.setDoOutput(true); - if (body != null && !body.isEmpty()) { - try (OutputStream os = conn.getOutputStream()) { - os.write(body.getBytes(StandardCharsets.UTF_8)); - } - } - if (conn.getResponseCode() == 200) { - return readBody(conn); - } - } catch (Exception e) { - log.warn("[nacos] POST {} 失败:{}", urlStr, e.getMessage()); - } finally { - if (conn != null) { - conn.disconnect(); - } - } - return null; - } - - private String readBody(HttpURLConnection conn) throws Exception { - try (BufferedReader br = new BufferedReader( - new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { - StringBuilder sb = new StringBuilder(); - String line; - while ((line = br.readLine()) != null) { - sb.append(line); - } - return sb.toString(); - } - } - - private String enc(String s) { - try { - return URLEncoder.encode(s == null ? "" : s, "UTF-8"); - } catch (Exception e) { - return s == null ? "" : s; - } - } - - private String firstNonNull(String... vals) { - for (String v : vals) { - if (v != null && !v.isEmpty()) { - return v; - } - } - return null; - } - - private String str(Map m, String key) { - if (m == null) { - return null; - } - Object v = m.get(key); - return v == null ? null : String.valueOf(v); - } - - @Override - public int getOrder() { - // 在 ConfigFileApplicationListener(HIGHEST_PRECEDENCE+10,负责加载 application.yml)之后执行, - // 确保 application.yml 已加载,从而能把它作为插入锚点并覆盖它 - return Ordered.LOWEST_PRECEDENCE; - } -} diff --git a/src/main/java/org/apereo/cas/web/custom/controller/LoginController.java b/src/main/java/org/apereo/cas/web/custom/controller/LoginController.java index e534c89..ab99ade 100644 --- a/src/main/java/org/apereo/cas/web/custom/controller/LoginController.java +++ b/src/main/java/org/apereo/cas/web/custom/controller/LoginController.java @@ -18,6 +18,7 @@ import org.apereo.cas.authentication.principal.Principal; import org.apereo.cas.authentication.principal.Service; import org.apereo.cas.authentication.principal.DefaultPrincipalFactory; import org.apereo.cas.authentication.principal.WebApplicationServiceFactory; +import org.apereo.cas.services.UnauthorizedServiceException; import org.apereo.cas.ticket.AbstractTicketException; import org.apereo.cas.ticket.ServiceTicket; import org.apereo.cas.ticket.TicketGrantingTicket; @@ -240,6 +241,83 @@ public class LoginController { return buildResponse(0, data, ""); } + /** + * SSO 预检:登录页加载时调用,判断当前浏览器是否已持有有效 TGT。 + * 已登录且有 service → 直接签发 ST 返回业务系统回跳地址(单点免密跳转的关键); + * 已登录无 service → 返回默认落点;未登录/票据失效 → loggedIn=false,前端正常展示表单。 + * 任何内部异常都按未登录降级,绝不向上抛(预检失败不应阻断正常登录流程)。 + */ + @GetMapping("/check-sso") + public JSONObject checkSso( + @RequestParam(value = "service", required = false) String service, + HttpServletRequest request) { + JSONObject data = new JSONObject(); + boolean loggedIn = false; + String url = ""; + try { + String tgtId = ticketGrantingTicketCookieGenerator.retrieveCookieValue(request); + if (tgtId != null && !tgtId.trim().isEmpty()) { + TicketGrantingTicket tgt = null; + try { + tgt = centralAuthenticationService.getTicket(tgtId, TicketGrantingTicket.class); + } catch (AbstractTicketException e) { + // 票据不存在/已过期:按未登录处理 + logger.info("check-sso:TGT {} 不存在或已过期", tgtId); + } + if (tgt != null && !tgt.isExpired()) { + loggedIn = true; + String targetUrl = signServiceTicketFor(tgt.getId(), service); + if (targetUrl == null) { + // 无 service 或 service 非法(未注册/校验被拒):回默认落点 + targetUrl = buildDefaultFallbackUrl(request); + } + url = targetUrl; + } + } + } catch (Exception e) { + logger.warn("check-sso 预检异常,按未登录降级处理", e); + loggedIn = false; + url = ""; + } + data.put("loggedIn", loggedIn); + data.put("url", url); + return buildResponse(0, data, ""); + } + + /** + * 为已持有的 TGT 向指定 service 签发 ServiceTicket,返回带 ticket 的回跳地址; + * 无 service / service 未注册(UnauthorizedServiceException)/ 其它异常返回 null。 + * 注意:这里不做防暴破介入——凭有效 TGT 签 ST 是 CAS 原生单点行为,无需验证码。 + */ + private String signServiceTicketFor(String tgtId, String service) { + if (service == null || service.trim().isEmpty()) { + return null; + } + try { + try { + service = URLDecoder.decode(service, "UTF-8"); + } catch (Exception e) { + logger.warn("service 参数 URL 解码失败,按原值使用: {}", service); + } + Service svc = new WebApplicationServiceFactory().createService(service.trim()); + if (svc == null) { + return null; + } + ServiceTicket st = centralAuthenticationService.grantServiceTicket(tgtId, svc, null); + String svcUrl = svc.getId(); + return svcUrl + (svcUrl.contains("?") ? "&" : "?") + "ticket=" + st.getId(); + } catch (UnauthorizedServiceException e) { + logger.warn("check-sso:service 未在注册表中授权: {}", service); + return null; + } catch (AbstractTicketException | AuthenticationException e) { + logger.warn("check-sso:签发 ServiceTicket 失败", e); + return null; + } catch (Exception e) { + logger.error("check-sso:签发 ServiceTicket 异常", e); + return null; + } + } + @GetMapping("/public-key") public JSONObject publicKey() { if (rsaPublicKey == null || rsaPublicKey.trim().isEmpty()) { @@ -305,7 +383,7 @@ public class LoginController { writeJson(response, buildResponse(1, data, msg)); } else { // 原生表单提交:302 回登录页并带错误提示;同时让浏览器在成功时记住密码。 - String target = "/front/index.html?error=" + URLEncoder.encode(msg, "UTF-8"); + String target = "/cas/login?error=" + URLEncoder.encode(msg, "UTF-8"); response.sendRedirect(target); } } @@ -424,13 +502,7 @@ public class LoginController { } // 返回绝对 URL,确保前端无论从哪个 origin 打开都能正确跳回 CAS 同源, // 从而把 TGC Cookie 一起带过去(相对路径 /cas/login 在非同源前端下会跳错 origin)。 - // 防御:默认落点若未配置、或误配成 CAS 原生登录页路径,则强制回退到前端首页, - // 避免出现“登录成功却被甩回 /cas/login”的死循环(曾因 application.yml 误配踩过)。 - String target = defaultRedirectUrl; - if (target == null || target.trim().isEmpty() || target.contains("/cas/login")) { - target = "/front/index.html"; - } - return buildCasAbsoluteUrl(request, target); + return buildDefaultFallbackUrl(request); } catch (AuthenticationException | AbstractTicketException e) { logger.error("用户 {} 建立 SSO 会话失败", username, e); return null; @@ -440,6 +512,19 @@ public class LoginController { } } + /** + * 构造默认落点(无 service / service 未注册 / 未配置时的回退地址)。 + * 默认落点若未配置、或误配成 CAS 原生登录页路径,则强制回退到业务系统首页, + * 避免出现“登录成功却被甩回 /cas/login”的死循环(曾因 application.yml 误配踩过)。 + */ + private String buildDefaultFallbackUrl(HttpServletRequest request) { + String target = defaultRedirectUrl; + if (target == null || target.trim().isEmpty() || target.contains("/cas/login")) { + target = "https://base.yunhaixingtong.com"; + } + return buildCasAbsoluteUrl(request, target); + } + /** * 基于当前请求推断 CAS 自身的访问基地址(scheme://host:port),用于构造绝对跳转 URL, * 避免前端页面不在 CAS 同源时相对路径跳转错 origin,导致 TGC Cookie 无法随跳转携带。 diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories deleted file mode 100644 index 8759f02..0000000 --- a/src/main/resources/META-INF/spring.factories +++ /dev/null @@ -1,3 +0,0 @@ -# 注册自定义 Nacos 配置注入器:在 Spring Boot 环境准备阶段把 Nacos 配置注入 Environment -org.springframework.boot.env.EnvironmentPostProcessor=\ - org.apereo.cas.web.custom.config.nacos.NacosEnvironmentPostProcessor diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a3ba522..d814fbb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -100,11 +100,13 @@ custom: CQIDAQAB private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCrK1seBBZlCaTCanpadxVpSZQ2ATFgMHRKnyHWp/Ip+59tfYwQzpTNI9AcjC6sWqTNweWN2sYMJdZnZe/xzwLbv4w48t/hXJhDuKe+q2+YTBLpzeSbhCpBV2uHQMmNViGA7yt4TwuGUMVz1WH0XDJJvylsLUQzSzaU3qKDv/01Hz4KrFbrWS77tVicvNxGClzRwXcr+ZDACNV/WkqMa08bWaks1welPbKnm73nQZ8W12piAC17uTgIOXAwMp+l/7Y+DmRkDOiA7kNOr9CiU5aqNl+BPbAE2lhVP7eCVuGFFtz1Pf9KoV5P0qVdxqYFcu8ojpa4rGUAR4pOKixSAwoJAgMBAAECggEBAJsnYJ9zmw0Y0bS8qY2I9eqLHIJbUHNdWHwvhI+7ikHN6HRp4eV2K+FK/iByXPgz7cDoafjMfsqSHad5+iZYu8GO/j68wHzZ3tc78Sle4nfNNTOjQv5Irbiu0jQ2YLvxf8sMgH28QLkOS/y0RecnDxpT4etBmNlZ6u7EY9FC3PUhkTCb2Ioy+xhA9FNZLc9awmzuqLZMkhUThspRmEFoBDgQV/U0o9OVVi3nEecEUvOCQpv3kwpDZMJECNPxY+qnGs3uKl6hbWXVhhqO9XKOu3wN2AJmakugcBSD6NpFu+C+BOah/kMlcrUXgVQUeObhwXfQjrw4CNyrD1AVvtVWfwECgYEA4Q38R60oZtZqEXz2KvhOM4uvD3def+IrLa6xnjHtqOQ1m7Ok1wLvhVW3P37/HyYyuq3EhRo2drD1qI58wZ9QAvFSnrNMHwqbaYew4OciFs28zPcYgRR1dLEOcz/WBbqhixClrJX22imil/eqxktDamDetSlkKo9F+rInzQukWykCgYEAwrSWRGLjHdQYmeBShOFKb+OPAR2198QTZPN5DmNXVgRfp+cb7FQ7Mw8ZndpDlfu7ARTwhNv0y3piCj8bJtm7BgXaQowwTR0AxRDkqF6fUNVqKEyE25G1hhE2NCD/4b+rAO8IaCCNnjSvAWhXjg7sQHBngJhFxYY68NQx6wuv8+ECgYA9KcLda/bl8/HBrPdt+CuwoI7EIknhcYMQmECXYLRZSSQ0m575/EdCMmhp7kbBnWz+BQm6rsQMf81TsN3A9V4WXqV4kfvnF8fi50iv5sEB94oPHjxfoELHovLtaXTt+6m5lJ2iCOOcqOIScPHNkFjTCV6dV5Fsa6G2H0Ln2/kf+QKBgBJVZMzdrtsRLJvYpoyCljrA56dIP+krmlPoJR74tbGQyd0KgvOW3tgo9NWLRs/pmys36InqGDvMs0yZspnO6LBhfrLDdozcTUVBGPbNqhy3a76Nrfjq2HfeSQdRkCaWoKh+2YDMh+k8trngNFZZC1/p9aUDMZau0//3SxgByrihAoGAPyOKhvPuoQfkbe6kyqGOZoPmctr5mQiHAPzNoKQkGD0pAGrId77KfDjPY8qM0ZTCLuYUJHy3ISefgHKANHLBgPlQasHo6t2f5Wk/173ju+cruW07APe3imXeGkpL4cg4n1YWvSAkwr4GoDXvTd6uL15zM452oxcW8p4hdoITaP0= login: - # 登录成功(无 service 参数)时的默认落点。必须是静态前端页,绝不能指向 CAS 原生登录页 + # 登录成功(无 service 参数)时的默认落点(业务系统首页)。 + # 必须是绝对 URL(http(s)://...),绝不能指向 CAS 原生登录页 # (/cas/login 或 /login)——否则登录成功会被甩回 CAS 登录页形成死循环。 # 该值会被打包进 WAR 的 classpath application.yml,且会覆盖 LoginController 中 @Value 的默认值, - # 所以这里必须显式写成前端页路径。业务系统带 service 参数时仍走各自回跳地址。 - default-redirect-url: /front/index.html + # 所以这里必须显式写成业务系统首页地址。业务系统带 service 参数时仍走各自回跳地址。 + # 注意:生产环境由 Nacos cas.yaml 的 custom.login.default-redirect-url 覆盖(优先级更高),两处需保持一致。 + default-redirect-url: https://base.yunhaixingtong.com # 是否强制每次登录都必须校验验证码。 # false(默认)/ 不配置:维持原逻辑,仅当同一会话登录失败达到阈值后才要求验证码。 # true:任何登录(含首次)都必须携带并校验验证码,用于彻底阻断批量账号枚举探测。