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/.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/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/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/controller/LoginController.java b/src/main/java/org/apereo/cas/web/custom/controller/LoginController.java
index ab99ade..ac86bb6 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
@@ -115,21 +115,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();