diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..16c2d2d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# 缩小 docker build 上下文(Dockerfile 只需 target/cas.war、docker/*、etc/cas/config/log4j2.xml) +src +.gitea +.git +*.md +maven-settings.xml +docker/cas.war +docker/cas.war.bak diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 5e06537..96511b5 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -8,7 +8,8 @@ on: jobs: build-deploy: runs-on: ubuntu-latest - # docker:24 自带 docker CLI;宿主 docker.sock 默认挂入 job 容器。 + # docker:24 自带 docker CLI;宿主 docker.sock 默认挂入 job 容器, + # 因此 docker build/run 实际都在宿主守护进程上执行。 container: image: docker:24 steps: @@ -16,22 +17,35 @@ jobs: run: apk add --no-cache git - name: Checkout (manual, internal Gitea) - # cas.war (174MB) 已作为普通 blob 入库(服务端未启用 LFS) run: | git clone --depth 1 --branch main http://192.168.10.160:3000/${GITHUB_REPOSITORY}.git /tmp/src cp -a /tmp/src/. . rm -rf .git .gitea rm -rf /tmp/src - - name: Build image (copies docker/cas.war) on host daemon - # 经由挂载的 docker.sock 在宿主守护进程上构建,镜像直接落在宿主本地 + # 用宿主 docker 守护进程跑 maven 3.9(--network=host 走宿主网络拉 nexus 依赖)。 + # 重要:job 容器内 $PWD 不是宿主可见路径,不能直接 `-v $PWD:/build` 挂给 maven 容器 + # (docker.sock 跨边界 bind mount 会落到宿主空目录,maven 看不到 pom.xml)。 + # 改用 docker create + docker cp 把源码送进 maven 容器,构建完再把 target 拷回工作区。 + # war 由 maven 从源码构建(target/cas.war),不再把 war 提交进 git。 + - name: Build war (maven 3.9, nexus maven-public) + run: | + MVN=$(docker create --network=host -v /opt/docker/ubs-m2:/root/.m2 -w /build maven:3.9-eclipse-temurin-8 mvn -s maven-settings.xml -B -DskipTests package) + docker cp "$PWD/." "$MVN:/build" + docker start -a "$MVN" + docker cp "$MVN:/build/target" "$PWD/target" + docker rm -f "$MVN" + + - name: Build image on host daemon + # 构建上下文=仓库根目录,Dockerfile 内 COPY target/cas.war; + # 业务配置由 run-cas.sh 在启动时调用 nacos_config.py 从 Nacos 拉取(替代失效的 EPP)。 run: docker build -f docker/Dockerfile -t cassso-service:latest . - 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 环境变量:run-cas.sh 据此在启动阶段从 Nacos 拉取业务配置(common.yaml + cas.yaml, prod)。 # 不传 NACOS_NAMESPACE 会默认连 dev(dev 命名空间无配置),登录必失败——必须显式传 prod。 + # 注意:镜像构建成功才会走到本步;若上面 maven/docker build 失败,本步不执行,旧容器继续运行。 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 \ @@ -42,4 +56,4 @@ jobs: cassso-service:latest - name: Done - run: 'echo "CasSsoService deployed -> https://192.168.10.160:8443/login"' + run: 'echo "CasSsoService deployed -> http://sso.yunhaixingtong.com/cas"' diff --git a/.gitignore b/.gitignore index 47f7158..2ed50fd 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,10 @@ docker/diag2_out.txt # 前端由 CasSsoFront 仓库独立发布,本后端仓库只发纯后端(cas.war) src/main/webapp/front/ *.iml +# 构建产物与二进制不入库(CI 用 maven 从源码构建 cas.war) +target/ +*.war +docker/*.war +docker/cas.war.bak +nacos-config-injector.jar +_injector_build/ diff --git a/docker/Dockerfile b/docker/Dockerfile index 3e7bdf1..6f5dfc3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -# CAS 5.3.16 镜像(基于已构建好的 target/cas.war) +# CAS 5.3.16 镜像(cas.war 由 CI 用 maven 从源码构建,落在 target/cas.war) # 构建上下文 = 项目根目录: docker build -f docker/Dockerfile -t . FROM eclipse-temurin:8-jre @@ -7,18 +7,21 @@ RUN mkdir -p /etc/cas/config \ && keytool -genkeypair -alias cas -keyalg RSA -keysize 2048 -validity 3650 \ -storetype JKS -keystore /etc/cas/cas.keystore \ -storepass changeit -keypass changeit -dname "CN=cas.example.org" 2>/dev/null || true \ - && apt-get update && apt-get install -y --no-install-recommends curl \ + && apt-get update && apt-get install -y --no-install-recommends curl python3 python3-yaml \ && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY docker/cas.war /app/cas.war +# cas.war 由 maven 源码构建产出(target/cas.war),不再把 war 提交进 git +COPY target/cas.war /app/cas.war COPY docker/cas.properties /etc/cas/config/cas.properties +COPY docker/nacos_config.py /app/nacos_config.py 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.server.name / JDBC / Redis / always-captcha 等)由 run-cas.sh 在启动阶段 +# 调用 nacos_config.py 从 Nacos 原生拉取(common.yaml + cas.yaml,namespace=NACOS_NAMESPACE), +# 生成 /etc/cas/config/cas.properties(CAS 5.3 原生加载,优先级高于 classpath application.yml)。 +# 替代失效的 EnvironmentPostProcessor(CAS 5.3 + Spring Cloud bootstrap 下 EPP 不触发)。 EXPOSE 8443 ENTRYPOINT ["/app/run-cas.sh"] diff --git a/docker/cas.war b/docker/cas.war deleted file mode 100644 index f3155b0..0000000 Binary files a/docker/cas.war and /dev/null differ diff --git a/docker/nacos_config.py b/docker/nacos_config.py new file mode 100644 index 0000000..c76bbfa --- /dev/null +++ b/docker/nacos_config.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# 启动前从 Nacos 拉取 common.yaml + cas.yaml,生成 /etc/cas/config/cas.properties。 +# 替代失效的 EnvironmentPostProcessor(CAS 5.3 + Spring Cloud bootstrap 下 EPP 不触发)。 +# CAS 5.3 原生加载 /etc/cas/config/cas.properties(profile=standalone),优先级高于 classpath application.yml。 +import os, sys, json, urllib.request, urllib.parse, yaml + +ADDR = os.environ.get("NACOS_ADDR", "nacos.yunhaixingtong.com:8848") +NS = os.environ.get("NACOS_NAMESPACE", "dev") +USER = os.environ.get("NACOS_USER", "yh-reader") +PASS = os.environ.get("NACOS_PASS", "123456") +OUT = os.environ.get("CAS_PROPERTIES", "/etc/cas/config/cas.properties") + +# 库名默认值(cas.yaml 的 cas.authn.jdbc.query[0].database 仅用于拼 jdbc url,不作为独立属性写入) +DEFAULT_DB = "bsch_userbase" +DEFAULT_PARAMS = "useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true" + +# CAS 不识别的属性(写入会导致 RelaxedBinding 崩溃),从输出中排除 +EXCLUDE_KEYS = {"cas.authn.jdbc.query[0].database", "cas.authn.jdbc.query[0].dialect"} + +# 本地兜底配置(与运行环境无关,烤进 cas.properties) +LOCAL_CONFIG = """server.port: 8443 +server.ssl.enabled: true +server.ssl.key-store: /etc/cas/cas.keystore +server.ssl.key-store-password: changeit +server.ssl.key-password: changeit +server.ssl.key-alias: cas +logging.config: file:/etc/cas/config/log4j2.xml +cas.adminPagesSecurity.ip=127\\.0\\.0\\.1 +""" + + +def http_get(url, timeout=10): + try: + with urllib.request.urlopen(url, timeout=timeout) as r: + return r.read().decode("utf-8") if r.status == 200 else None + except Exception: + return None + + +def http_post(url, data=b"", timeout=10): + try: + req = urllib.request.Request(url, data=data, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.read().decode("utf-8") if r.status == 200 else None + except Exception: + return None + + +def login(): + url = "http://%s/nacos/v1/auth/login?username=%s&password=%s" % ( + ADDR, urllib.parse.quote(USER), urllib.parse.quote(PASS)) + resp = http_post(url) + if not resp: + return None + try: + return json.loads(resp).get("accessToken") + except Exception: + i = resp.find('"accessToken"') + if i < 0: + return None + s = resp.find('"', i + 14) + e = resp.find('"', s + 1) + return resp[s + 1:e] if s >= 0 and e > s else None + + +def resolve_ns(token): + if not NS: + return "" + url = "http://%s/nacos/v1/console/namespaces" % ADDR + if token: + url += "?accessToken=" + urllib.parse.quote(token) + body = http_get(url) + if not body: + return NS + try: + data = json.loads(body).get("data", []) + except Exception: + return NS + for item in data: + if NS == item.get("namespaceShowName") or NS == item.get("namespace"): + return item.get("namespace", "") + return NS + + +def fetch_yaml(data_id, group, nsid, token): + base = "http://%s/nacos/v1/cs/configs" % ADDR + qs = "dataId=%s&group=%s&tenant=%s" % ( + urllib.parse.quote(data_id), urllib.parse.quote(group), urllib.parse.quote(nsid)) + if token: + content = http_get("%s?%s&accessToken=%s" % (base, qs, urllib.parse.quote(token))) + if content and content.strip() and '"error"' not in content: + return content + return http_get("%s?%s" % (base, qs)) or "" + + +def flatten(prefix, value, out): + if isinstance(value, dict): + for k, v in value.items(): + flatten(k if not prefix else prefix + "." + k, v, out) + elif isinstance(value, list): + for i, v in enumerate(value): + flatten("%s[%d]" % (prefix, i) if prefix else "[%d]" % i, v, out) + elif value is not None: + out[prefix] = value + + +def parse_yaml(content): + if not content or not content.strip(): + return {} + data = yaml.safe_load(content) or {} + flat = {} + flatten("", data, flat) + return flat + + +def esc(v): + if isinstance(v, bool): + return "true" if v else "false" + s = str(v) + s = s.replace("\\", "\\\\") + return s + + +def build(common, cas): + merged = {} + # cas.yaml 透传(排除无效属性) + for k, v in cas.items(): + if k not in EXCLUDE_KEYS: + merged[k] = v + # datasource -> jdbc url(与 EPP buildCasProperties 逻辑一致) + ds_host = common.get("spring.datasource.host") or common.get("mysql.host") + ds_port = common.get("spring.datasource.port") or common.get("mysql.port") or 3306 + ds_user = common.get("spring.datasource.username") or common.get("mysql.user") + ds_pass = common.get("spring.datasource.password") or common.get("mysql.password") + ds_params = common.get("spring.datasource.params") or common.get("mysql.params") or DEFAULT_PARAMS + db_name = cas.get("cas.authn.jdbc.query[0].database", DEFAULT_DB) + if ds_host: + jdbc_url = "jdbc:mysql://%s:%s/%s?%s" % (ds_host, ds_port, db_name, ds_params) + merged["cas.authn.jdbc.query[0].url"] = jdbc_url + if ds_user: + merged["cas.authn.jdbc.query[0].user"] = ds_user + if ds_pass: + merged["cas.authn.jdbc.query[0].password"] = ds_pass + merged["cas.authn.jdbc.query[0].driverClass"] = "com.mysql.jdbc.Driver" + # 透传 redis / rabbitmq + for k, v in common.items(): + if k.startswith("spring.redis.") or k.startswith("spring.rabbitmq."): + merged[k] = v + return merged + + +def main(): + token = login() + nsid = resolve_ns(token) + common_raw = fetch_yaml("common.yaml", "DEFAULT_GROUP", nsid, token) + cas_raw = fetch_yaml("cas.yaml", "CAS", nsid, token) + common = parse_yaml(common_raw) + cas = parse_yaml(cas_raw) + if not common and not cas: + print("[nacos_config] WARN: 未拉到任何 Nacos 配置,保留现有 cas.properties", file=sys.stderr) + sys.exit(1) + merged = build(common, cas) + + lines = [ + "# 由 nacos_config.py 从 Nacos 生成(勿手改)", + "# namespace=%s nsid=%s common_keys=%d cas_keys=%d" % (NS, nsid, len(common), len(cas)), + ] + for k in sorted(merged.keys()): + lines.append("%s=%s" % (k, esc(merged[k]))) + lines.append("") + lines.append("# === 本地兜底配置 ===") + lines.append(LOCAL_CONFIG.rstrip("\n")) + + content = "\n".join(lines) + "\n" + os.makedirs(os.path.dirname(OUT), exist_ok=True) + with open(OUT, "w", encoding="utf-8") as f: + f.write(content) + print("[nacos_config] wrote %d props -> %s" % (len(merged), OUT)) + print("[nacos_config] always-captcha=%s redis.host=%s jdbc.host=%s server.name=%s" % ( + merged.get("custom.login.always-captcha"), + merged.get("spring.redis.host"), + common.get("spring.datasource.host"), + merged.get("cas.server.name"))) + + +if __name__ == "__main__": + main() diff --git a/docker/run-cas.sh b/docker/run-cas.sh index f6f9098..56defcb 100644 --- a/docker/run-cas.sh +++ b/docker/run-cas.sh @@ -1,12 +1,12 @@ #!/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 启动入口 +# 配置注入:启动前用 python3 从 Nacos 拉 common.yaml+cas.yaml 生成 /etc/cas/config/cas.properties。 +# 替代失效的 EnvironmentPostProcessor(CAS 5.3 + Spring Cloud bootstrap 下 EPP 不触发)。 +# CAS 5.3 原生加载 /etc/cas/config/cas.properties(profile=standalone),优先级高于 classpath application.yml。 set -e -echo "[run-cas] Nacos 原生配置注入:NACOS_ADDR=${NACOS_ADDR:-} NACOS_NAMESPACE=${NACOS_NAMESPACE:-} NACOS_USER=${NACOS_USER:-}" +echo "[run-cas] Nacos 配置注入:NACOS_ADDR=${NACOS_ADDR:-} NACOS_NAMESPACE=${NACOS_NAMESPACE:-} NACOS_USER=${NACOS_USER:-}" + +python3 /app/nacos_config.py || echo "[run-cas] WARN: nacos_config.py 失败,用镜像内 cas.properties 兜底" exec java -jar /app/cas.war diff --git a/maven-settings.xml b/maven-settings.xml new file mode 100644 index 0000000..2107fa9 --- /dev/null +++ b/maven-settings.xml @@ -0,0 +1,37 @@ + + + + + maven-default-http-blocker + external:http:* + nexus maven-public (allow http) + http://192.168.10.160:8081/repository/maven-public/ + false + + + + + nexus + + + central + http://192.168.10.160:8081/repository/maven-public/ + true + true + + + + + central + http://192.168.10.160:8081/repository/maven-public/ + true + true + + + + + + nexus + + diff --git a/pom.xml b/pom.xml index f1fd85d..d2ea58c 100644 --- a/pom.xml +++ b/pom.xml @@ -360,6 +360,14 @@ 4.2.10.RELEASE provided + + + + org.yaml + snakeyaml + 1.17 + provided + 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 index fc81160..9c33a3d 100644 --- 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 @@ -83,17 +83,33 @@ public class NacosEnvironmentPostProcessor implements EnvironmentPostProcessor, String casDataId = firstNonNull(environment.getProperty("nacos.config.cas-data-id"), "cas.yaml"); String casGroup = firstNonNull(environment.getProperty("nacos.config.cas-group"), "CAS"); + System.out.println("[EPP-DEBUG] ===== postProcessEnvironment START ====="); + System.out.println("[EPP-DEBUG] NACOS_ADDR=" + addr + " NACOS_NAMESPACE=" + namespace + + " NACOS_USER=" + username + " commonDataId=" + commonDataId + "/" + commonGroup + + " casDataId=" + casDataId + "/" + casGroup); try { String token = login(addr, username, password); + System.out.println("[EPP-DEBUG] login token=" + (token == null ? "null" : token.substring(0, Math.min(20, token.length())) + "...")); // 把命名空间名字(dev/prod)解析成 Nacos 的 namespaceId: // 配置接口 tenant 参数只认 ID 不认名字,直接传 "prod" 会查不到配置。 String resolvedNs = resolveNamespace(addr, namespace, token); + System.out.println("[EPP-DEBUG] resolvedNs=" + resolvedNs); Map common = fetchYaml(addr, commonDataId, commonGroup, resolvedNs, token); Map cas = fetchYaml(addr, casDataId, casGroup, resolvedNs, token); + System.out.println("[EPP-DEBUG] common keys=" + (common == null ? 0 : common.size()) + + " cas keys=" + (cas == null ? 0 : cas.size())); + if (cas != null) { + System.out.println("[EPP-DEBUG] cas always-captcha=" + cas.get("custom.login.always-captcha") + + " server.name=" + cas.get("cas.server.name")); + } Map merged = buildCasProperties(common, cas); + System.out.println("[EPP-DEBUG] merged size=" + merged.size() + + " always-captcha=" + merged.get("custom.login.always-captcha") + + " redis.host=" + merged.get("spring.redis.host")); if (merged.isEmpty()) { + System.out.println("[EPP-DEBUG] merged EMPTY, fallback application.yml"); log.warn("[nacos] 未从 Nacos 解析出任何有效配置,回退 application.yml"); return; } @@ -107,8 +123,11 @@ public class NacosEnvironmentPostProcessor implements EnvironmentPostProcessor, } else { sources.addFirst(source); } + System.out.println("[EPP-DEBUG] INJECTED OK before=" + appName + " size=" + merged.size()); log.info("[nacos] 已从 Nacos 注入 {} 个配置项 (namespace='{}' -> '{}', addr={})", merged.size(), namespace, resolvedNs, addr); } catch (Exception e) { + System.out.println("[EPP-DEBUG] FAILED: " + e); + e.printStackTrace(); log.warn("[nacos] 加载 Nacos 配置失败,回退 application.yml:{}", e.getMessage()); } } 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..a72e9a4 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 @@ -114,21 +114,21 @@ public class LoginController { @RequestParam("pwd") String encryptedPassword, @RequestParam(value = "captcha", required = false) String captchaText, @RequestParam(value = "service", required = false) String service, - HttpServletRequest request, - HttpServletResponse response) throws IOException { + HttpServletRequest request, + HttpServletResponse response) throws IOException { - // 前端未对 service 做 URL 解码(bschlogin.js getQueryVariable 直接 slice 不做 decodeURIComponent), - // 会把浏览器地址栏里百分号编码态的 service(http%3A%2F%2F...)原样 POST 给后端,导致 - // WebApplicationServiceFactory.createService 拿到的仍是编码串,CAS 服务注册表正则 - // ^(https|imaps|http)://.* 匹配不到而抛 UnauthorizedServiceException(带 service 登录报“建立会话失败”)。 - // 统一在此把 service 解码成真实 URL(http://...)并向后透传解码值,方便后续其它逻辑直接读取。 - if (service != null && !service.trim().isEmpty()) { - try { - service = URLDecoder.decode(service, "UTF-8"); - } catch (Exception e) { - logger.warn("service 参数 URL 解码失败,按原值使用: {}", service); - } + // 前端未对 service 做 URL 解码(bschlogin.js getQueryVariable 直接 slice 不做 decodeURIComponent), + // 会把浏览器地址栏里百分号编码态的 service(http%3A%2F%2F...)原样 POST 给后端,导致 + // WebApplicationServiceFactory.createService 拿到的仍是编码串,CAS 服务注册表正则 + // ^(https|imaps|http)://.* 匹配不到而抛 UnauthorizedServiceException(带 service 登录报“建立会话失败”)。 + // 统一在此把 service 解码成真实 URL(http://...)并向后透传解码值,方便后续其它逻辑直接读取。 + if (service != null && !service.trim().isEmpty()) { + try { + service = URLDecoder.decode(service, "UTF-8"); + } catch (Exception e) { + logger.warn("service 参数 URL 解码失败,按原值使用: {}", service); } + } HttpSession session = request.getSession();