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();