diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..265d25f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +# 缩小 docker build 上下文(Dockerfile 只需 target/app.jar、docker/cas.properties、etc/cas/config/log4j2.xml) +src +.gitea +.git +.idea +*.md +maven-settings.xml +build.cmd +build.sh diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 1ac4602..7353587 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -50,7 +50,8 @@ jobs: # 通过 docker.sock 调宿主 dockerd,故 -v 路径按宿主解析;用 docker cp 进出源码/产物 # m2 缓存挂宿主固定目录 /opt/docker/projman-m2,跨构建复用依赖 # settings.xml 用 profile 方式追加 nexus maven-public 仓库(匿名可拉,不带凭据) - # maven 实际产物名 = artifactId-version.jar(随 pom 变,-DfinalName 命令行 property 不可靠覆盖不了), + # 本项目为 CAS 5.3.16 war overlay(packaging=war,finalName=cas),mvn package 产物 = target/cas.war + # (spring-boot repackage 后为可执行 WAR,WarLauncher 可直接 java -jar), # 故 mvn package 后在容器内 cp 成固定名 app.jar,使 docker cp 与 docker/Dockerfile 的 COPY target/app.jar 绑定 - name: 构建 jar(maven 容器,复用宿主 m2 缓存) run: | @@ -63,7 +64,7 @@ jobs: docker cp "${PWD}/." mavenbuild:/build/ docker exec mavenbuild sh -c "printf '%s' 'nexus-reponexus-internalhttp://nexus:8081/repository/maven-public/truetruealwaysnexus-repo' > /root/.m2/settings.xml" docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package' - docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar' + docker exec mavenbuild sh -c 'cd /build/target && cp cas.war app.jar' mkdir -p "${PWD}/target" docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar" docker rm -f mavenbuild diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..381e170 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# 前端由 CasSsoFront 仓库独立发布,本后端仓库只发纯后端(cas.war) +src/main/webapp/front/ +*.iml +.idea/ +# 构建产物与二进制不入库(CI 用 maven 从源码构建 cas.war) +target/ +overlays/ +build/ +*.war +docker/*.war +nacos-config-injector.jar +_injector_build/ diff --git a/README.md b/README.md index b9749d5..3498e01 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,16 @@ # CasService -> 本仓库由 projman 自动创建并注入 CI(`.gitea/workflows/build.yml`)与 Dockerfile(`docker/Dockerfile`)。 -> 推送代码到 main 分支即自动构建镜像并推送到 Nexus,projman 收到通知后建档待部署,由运维在 projman「后端发布」手动点部署。 +CAS 5.3.16 单点登录服务(war overlay 项目,源自 CasSsoService 改版迁移)。本仓库由 projman 创建并注入 CI 与 Dockerfile,已按 CAS 项目形态适配。 -## 首次推送本地代码到本仓库 +## 与标准 springboot CI 模板的差异 -> 目标:把本地已有代码推上去,同时**保留** projman 注入的文件(`build.yml`、`Dockerfile`)。 - -```bash -# 1. 进入本地代码目录 -cd /your/local/code/dir - -# 2. 初始化 git(目录已有 .git 也安全,git init 幂等无副作用) -git init -git branch -M main - -# 3. 先把本地代码提交成一个 commit(关键!不提交直接 merge 会丢失本地代码) -git add -A -git commit -m "init local code" - -# 4. 关联远程仓库(幂等:已存在 origin 则改地址,不报错) -git remote add origin http://gitea.yunhaixingtong.com/cas/CasService.git 2>/dev/null \ - || git remote set-url origin http://gitea.yunhaixingtong.com/cas/CasService.git - -# 5. 拉取远程信息(只下载,不合并) -git fetch origin - -# 6. 合并远程 main(关键步骤) -# 本地与远程是两条独立历史,需允许合并不相关历史。 -# 此步会把 projman 注入的 build.yml 与 Dockerfile 合并进你的本地工作区。 -git merge origin/main --allow-unrelated-histories -m "merge projman injected files" - -# 7. 查看状态(确认 .gitea/workflows/build.yml、docker/Dockerfile 已在本地) -git status - -# 8. 提交并推送 -git add -A -git commit -m "sync local code" 2>/dev/null || true -git push origin main -``` +- **构建产物是 WAR 不是 jar**:pom `packaging=war`、`finalName=cas`,CI 在 maven 构建后 `cp cas.war app.jar`(可执行 WAR,WarLauncher 直接 `java -jar`)。 +- **Dockerfile 为 CAS 专用**:镜像内生成自签名 keystore(8443 HTTPS),内置 `docker/cas.properties`(仅 TLS/端口/日志)与 `etc/cas/config/log4j2.xml`;业务配置由 projman 配置管理挂载 `/app/config/application.yml`。 +- **健康检查**:`/cas/status`(HTTPS 自签名),projman 应用需配置 healthPath=`/cas/status` + HTTPS 开。 +- **pom 不声明外网仓库**:依赖全部经 nexus maven-public 代理解析(内网 CI 无外网)。 +- 前端登录页静态文件由 CasSsoFront 仓库独立发布(`src/main/webapp/front/` 不入库)。 ## 推送后验证 -push 触发 CI 构建,前往 Gitea 仓库 → **Actions** 查看日志;构建成功后镜像推送到 Nexus,projman 建档待部署,在 projman 应用详情「后端发布」手动点部署。 +push 到 main 触发 CI 构建,前往 Gitea 仓库 → **Actions** 查看日志;构建成功后镜像推送到 Nexus(`docker-hosted/cas-service:{sha8}`),projman 建档待部署,在 projman 应用详情「后端发布」手动点部署。 -## 禁止写法(会导致注入文件被删) - -`git reset origin/main` + `git add -A` 会把远程已注入、但你本地没有的文件(`build.yml`、`Dockerfile`)当作"删除"提交并推送,导致这些文件被删、CI 失败。务必用 `git merge` 而非 `git reset`,且 merge 前先提交本地代码。 - -## 关于 Dockerfile - -projman 注入的 Dockerfile 仅在仓库不存在时写入;你自备的 Dockerfile 优先,不会被覆盖。 diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..7ad4aba --- /dev/null +++ b/build.cmd @@ -0,0 +1,105 @@ +@echo off + +@set JAVA_ARGS=-Xms500m -Xmx1g +@if "%CAS_SERVER_NAME%" == "" set CAS_SERVER_NAME=http://localhost:8443 +@if "%CAS_SERVER_PREFIX%" == "" set CAS_SERVER_PREFIX=http://localhost:8443/cas +@set CAS_JAVA_OVERRIDES=-Dserver.use-forward-headers=false -Dcas.server.name=%CAS_SERVER_NAME% -Dcas.server.prefix=%CAS_SERVER_PREFIX% +@set CAS_DIR=\etc\cas +@set CONFIG_DIR=\etc\cas\config + +@rem Call this script with DNAME and CERT_SUBJ_ALT_NAMES already set to override +@if "%DNAME%" == "" set DNAME=CN=cas.example.org,OU=Example,OU=Org,C=US +@rem List other host names or ip addresses you want in your certificate, may help with host name verification, +@rem if client apps make https connection for ticket validation and compare name in cert (include sub. alt. names) +@rem to name used to access CAS +@if "%CERT_SUBJ_ALT_NAMES%" == "" set CERT_SUBJ_ALT_NAMES=dns:example.org,dns:localhost,dns:%COMPUTERNAME%,ip:127.0.0.1 + +@rem Check for mvn in path, use it if found, otherwise use maven wrapper +@set MAVEN_CMD=mvn +@where /q mvn +@if %ERRORLEVEL% neq 0 set MAVEN_CMD=.\mvnw.bat + +@if "%1" == "" call:help +@if "%1" == "copy" call:copy +@if "%1" == "clean" call:clean %2 %3 %4 +@if "%1" == "package" call:package %2 %3 %4 +@if "%1" == "bootrun" call:bootrun %2 %3 %4 +@if "%1" == "debug" call:debug %2 %3 %4 +@if "%1" == "run" call:run %2 %3 %4 +@if "%1" == "runalone" call:runalone %2 %3 %4 +@if "%1" == "help" call:help +@if "%1" == "gencert" call:gencert +@if "%1" == "cli" call:runcli %2 %3 %4 + +@rem function section starts here +@goto:eof + +:copy + @echo "Creating configuration directory under %CONFIG_DIR%" + if not exist %CONFIG_DIR% mkdir %CONFIG_DIR% + + @echo "Copying configuration files from etc/cas to /etc/cas" + xcopy /S /Y etc\cas\* \etc\cas +@goto:eof + +:help + @echo "Usage: build.bat [copy|clean|package|run|debug|bootrun|gencert|cli] [optional extra args for maven or cli]" + @echo "To get started on a clean system, run "build.bat copy" and "build.bat gencert", then "build.bat run" + @echo "Note that using the copy or gencert arguments will create and/or overwrite the %CAS_DIR% which is outside this project" +@goto:eof + +:clean + call %MAVEN_CMD% clean %1 %2 %3 + exit /B %ERRORLEVEL% +@goto:eof + +:package + call %MAVEN_CMD% clean package -T 5 %1 %2 %3 + exit /B %ERRORLEVEL% +@goto:eof + +:bootrun + call %MAVEN_CMD% clean package spring-boot:run -T 5 %1 %2 %3 + exit /B %ERRORLEVEL% +@goto:eof + +:debug + call:package %1 %2 %3 & java %JAVA_ARGS% %CAS_JAVA_OVERRIDES% -Xdebug -Xrunjdwp:transport=dt_socket,address=5000,server=y,suspend=n -jar target/cas.war +@goto:eof + +:run + call:package %1 %2 %3 & java %JAVA_ARGS% %CAS_JAVA_OVERRIDES% -jar target/cas.war +@goto:eof + +:runalone + call:package %1 %2 %3 & target/cas.war +@goto:eof + +:gencert + where /q keytool + if ERRORLEVEL 1 ( + @echo Java keytool.exe not found in path. + exit /b 1 + ) else ( + if not exist %CAS_DIR% mkdir %CAS_DIR% + @echo on + @echo Generating self-signed SSL cert for %DNAME% in %CAS_DIR%\thekeystore + keytool -genkeypair -alias cas -keyalg RSA -keypass changeit -storepass changeit -keystore %CAS_DIR%\thekeystore -dname %DNAME% -ext SAN=%CERT_SUBJ_ALT_NAMES% + @echo Exporting cert for use in trust store (used by cas clients) + keytool -exportcert -alias cas -storepass changeit -keystore %CAS_DIR%\thekeystore -file %CAS_DIR%\cas.cer + ) +@goto:eof + +:runcli + for /f %%i in ('call %MAVEN_CMD% -q --non-recursive "-Dexec.executable=cmd" "-Dexec.args=/C echo ${cas.version}" "org.codehaus.mojo:exec-maven-plugin:1.3.1:exec"') do set CAS_VERSION=%%i + @set CAS_VERSION=%CAS_VERSION: =% + @set DOWNLOAD_DIR=target + @set COMMAND_FILE=cas-server-support-shell-%CAS_VERSION%.jar + @if not exist %DOWNLOAD_DIR% mkdir %DOWNLOAD_DIR% + @if not exist %DOWNLOAD_DIR%\%COMMAND_FILE% ( + @call mvn org.apache.maven.plugins:maven-dependency-plugin:3.0.2:get -DgroupId=org.apereo.cas -DartifactId=cas-server-support-shell -Dversion=%CAS_VERSION% -Dpackaging=jar -DartifactItem.outputDirectory=%DOWNLOAD_DIR% -DartifactItem.destFileName=%COMMAND_FILE% -DremoteRepositories=central::default::http://repo1.maven.apache.org/maven2,snapshots::::https://oss.sonatype.org/content/repositories/snapshots -Dtransitive=false + @call mvn org.apache.maven.plugins:maven-dependency-plugin:3.0.2:copy -Dmdep.useBaseVersion=true -Dartifact=org.apereo.cas:cas-server-support-shell:%CAS_VERSION%:jar -DoutputDirectory=%DOWNLOAD_DIR% + ) + @call java %JAVA_ARGS% -jar %DOWNLOAD_DIR%\%COMMAND_FILE% %1 %2 %3 + +@goto:eof diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..4d80aa2 --- /dev/null +++ b/build.sh @@ -0,0 +1,189 @@ +#!/bin/bash + + +function copy() { + echo -e "Creating configuration directory under /etc/cas" + mkdir -p /etc/cas/config + + echo -e "Copying configuration files from etc/cas to /etc/cas" + cp -rfv etc/cas/* /etc/cas +} + +function help() { + echo "Usage: build.sh [copy|clean|package|run|debug|bootrun|gencert]" + echo " copy: Copy config from ./etc/cas/config to /etc/cas/config" + echo " clean: Clean Maven build directory" + echo " package: Clean and build CAS war" + echo " run: Build and run cas.war via Java (i.e. java -jar target/cas.war)" + echo " runalone: Build and run cas.war on its own as a standalone executable (target/cas.war)" + echo " debug: Run CAS.war and listen for Java debugger on port 5000" + echo " bootrun: Run with maven spring boot plugin" + echo " listviews: List all CAS views that ship with the web application and can be customized in the overlay" + echo " getview: Ask for a view name to be included in the overlay for customizations" + echo " gencert: Create keystore with SSL certificate in location where CAS looks by default" + echo " cli: Run the CAS command line shell and pass commands" +} + +function clean() { + shift + ./mvnw clean "$@" +} + +function package() { + shift + ./mvnw clean package -T 5 "$@" + # copy +} + +function bootrun() { + shift + ./mvnw clean package spring-boot:run -P bootiful -T 5 "$@" +} + +function debug() { + package && java -Xdebug -Xrunjdwp:transport=dt_socket,address=5000,server=y,suspend=n -jar target/cas.war +} + +function run() { + package && java -jar target/cas.war +} + +function runalone() { + shift + ./mvnw clean package -P default,exec "$@" + chmod +x target/cas.war + target/cas.war +} + +function listviews() { + shift + explodeapp + find $PWD/target/cas -type f -name "*.html" | xargs -n 1 basename | sort | more +} + +function explodeapp() { + if [ ! -d $PWD/target/cas ];then + echo "Building the CAS web application and exploding the final war file..." + ./mvnw clean package war:exploded "$@" + fi + echo "Exploded the CAS web application file." +} + +function getview() { + shift + explodeapp + echo "Searching for view name $@..." + results=`find $PWD/target/cas -type f -name "*.html" | grep -i "$@"` + echo -e "Found view(s): \n$results" + count=`wc -w <<< "$results"` + if [ "$count" -eq 1 ];then + # echo "Found view $results to include in the overlay" + firststring="target/cas/WEB-INF/classes" + secondstring="src/main/resources" + overlayfile=`echo "${results/$firststring/$secondstring}"` + overlaypath=`dirname "${overlayfile}"` + # echo "Overlay file is $overlayfile to be created at $overlaypath" + mkdir -p $overlaypath + cp $results $overlaypath + echo "Created view at $overlayfile" + ls $overlayfile + else + echo "More than one view file is found. Narrow down the search query..." + fi +} + + +function gencert() { + if [[ ! -d /etc/cas ]] ; then + copy + fi + which keytool + if [[ $? -ne 0 ]] ; then + echo Error: Java JDK \'keytool\' is not installed or is not in the path + exit 1 + fi + # override DNAME and CERT_SUBJ_ALT_NAMES before calling or use dummy values + DNAME="${DNAME:-CN=cas.example.org,OU=Example,OU=Org,C=US}" + CERT_SUBJ_ALT_NAMES="${CERT_SUBJ_ALT_NAMES:-dns:example.org,dns:localhost,ip:127.0.0.1}" + echo "Generating keystore for CAS with DN ${DNAME}" + keytool -genkeypair -alias cas -keyalg RSA -keypass changeit -storepass changeit -keystore /etc/cas/thekeystore -dname ${DNAME} -ext SAN=${CERT_SUBJ_ALT_NAMES} + keytool -exportcert -alias cas -storepass changeit -keystore /etc/cas/thekeystore -file /etc/cas/cas.cer +} + +function cli() { + + CAS_VERSION=$(./mvnw -q -Dexec.executable="echo" -Dexec.args='${cas.version}' --non-recursive org.codehaus.mojo:exec-maven-plugin:1.3.1:exec 2>/dev/null) + # echo "CAS version: $CAS_VERSION" + JAR_FILE_NAME="cas-server-support-shell-${CAS_VERSION}.jar" + # echo "JAR name: $JAR_FILE_NAME" + JAR_PATH="org/apereo/cas/cas-server-support-shell/${CAS_VERSION}/${JAR_FILE_NAME}" + # echo "JAR path: $JAR_PATH" + + JAR_FILE_LOCAL="$HOME/.m2/repository/$JAR_PATH"; + # echo "Local JAR file path: $JAR_FILE_LOCAL"; + if [ -f "$JAR_FILE_LOCAL" ]; then + # echo "Using JAR file locally at $JAR_FILE_LOCAL" + java -jar $JAR_FILE_LOCAL "$@" + exit 0; + fi + + DOWNLOAD_DIR=./target + COMMAND_FILE="${DOWNLOAD_DIR}/${JAR_FILE_NAME}" + if [ ! -f "$COMMAND_FILE" ]; then + mkdir -p $DOWNLOAD_DIR + ./mvnw org.apache.maven.plugins:maven-dependency-plugin:3.0.2:get -DgroupId=org.apereo.cas -DartifactId=cas-server-support-shell -Dversion=$CAS_VERSION -Dpackaging=jar -DartifactItem.outputDirectory=$DOWNLOAD_DIR -DremoteRepositories=central::default::http://repo1.maven.apache.org/maven2,snapshots::::https://oss.sonatype.org/content/repositories/snapshots -Dtransitive=false + ./mvnw org.apache.maven.plugins:maven-dependency-plugin:3.0.2:copy -Dmdep.useBaseVersion=true -Dartifact=org.apereo.cas:cas-server-support-shell:$CAS_VERSION:jar -DoutputDirectory=$DOWNLOAD_DIR + fi + java -jar $COMMAND_FILE "$@" + exit 0; + +} + +if [ $# -eq 0 ]; then + echo -e "No commands provided. Defaulting to [run]\n" + run + exit 0 +fi + +case "$1" in +"copy") + copy + ;; +"clean") + shift + clean "$@" + ;; +"package") + shift + package "$@" + ;; +"bootrun") + shift + bootrun "$@" + ;; +"debug") + debug "$@" + ;; +"run") + run "$@" + ;; +"runalone") + runalone "$@" + ;; +"listviews") + listviews "$@" + ;; +"gencert") + gencert "$@" + ;; +"getview") + getview "$@" + ;; +"cli") + shift + cli "$@" + ;; +*) + help + ;; +esac diff --git a/docker/Dockerfile b/docker/Dockerfile index 2eec1d5..e725771 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,18 +1,27 @@ -# 由 projman 注入的 Spring Boot 运行镜像(单阶段,jar 预构建) -# CI 已先 mvn package,并在容器内把产物 cp 成固定名 target/app.jar +# CAS 5.3.16 运行镜像(单阶段,war 预构建) +# CI 已先 mvn package 并在容器内把产物 cp 成固定名 target/app.jar(= cas.war,可执行 WAR) # 构建上下文 = 仓库根:docker build -f docker/Dockerfile . -# 健康检查依赖 curl(projman 部署用 curl 探 /actuator/health)——应用需含 spring-boot-starter-actuator +# 健康检查(projman 应用配置 healthPath=/cas/status + HTTPS 自签名开):curl -fk https://localhost:8443/cas/status # 时区 Asia/Shanghai;JAVA_OPTS 由 projman 部署 env 注入(默认 -Xms512m -Xmx1024m) FROM eclipse-temurin:8-jre -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl tzdata \ +# CAS 5.3 默认要求 HTTPS(安全 Cookie),生成自签名密钥库(密码固定 changeit,过渡期用) +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" \ + && apt-get update && apt-get install -y --no-install-recommends curl tzdata \ && ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && echo "Asia/Shanghai" > /etc/timezone \ && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY target/app.jar /app/app.jar +COPY target/app.jar /app/cas.war +COPY docker/cas.properties /etc/cas/config/cas.properties +COPY etc/cas/config/log4j2.xml /etc/cas/config/log4j2.xml -EXPOSE 10001 -ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar"] +# 镜像内 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 ["sh", "-c", "java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/cas.war"] diff --git a/docker/cas.properties b/docker/cas.properties new file mode 100644 index 0000000..844a242 --- /dev/null +++ b/docker/cas.properties @@ -0,0 +1,14 @@ +# CAS 烤进镜像的本地兜底配置(与运行环境无关、不含任何凭据) +# 业务配置来自 projman 配置管理挂载的 /app/config/application.yml: +# -> cas.server.name / cas.authn.jdbc / Redis / custom.login.* 等全部由挂载文件提供,本文件不写。 +# 仅保留本地必需的:HTTPS 密钥库、端口、日志、管理页白名单。 +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 diff --git a/etc/cas/config/log4j2.xml b/etc/cas/config/log4j2.xml new file mode 100644 index 0000000..e688cc0 --- /dev/null +++ b/etc/cas/config/log4j2.xml @@ -0,0 +1,117 @@ + + + + + + . + + warn + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..0b292e3 --- /dev/null +++ b/pom.xml @@ -0,0 +1,351 @@ + + + 4.0.0 + org.apereo.cas + cas-overlay + war + 1.0 + + + 5.3.16 + 1.5.18.RELEASE + -tomcat + org.springframework.boot.loader.WarLauncher + false + ${project.build.directory}/war/work/org.apereo.cas/cas-server-webapp${app.server}/META-INF/MANIFEST.MF + 1.8 + 1.8 + UTF-8 + + 3.0.11.RELEASE + 3.0.11.RELEASE + + 4.3.25.RELEASE + 2.9.9 + 8.5.47 + 2.12.1 + 3.2.0 + 4.2.10.RELEASE + 2.6.2 + 1.11 + 3.2.5 + + + + + + + org.springframework.boot + spring-boot-dependencies + ${springboot.version} + pom + import + + + ch.qos.logback + logback-classic + + + ch.qos.logback + logback-core + + + org.apache.logging.log4j + log4j-slf4j-impl + + + + + + + + + + com.rimerosolutions.maven.plugins + wrapper-maven-plugin + 0.0.5 + + true + MD5 + + + + org.springframework.boot + spring-boot-maven-plugin + ${springboot.version} + + ${mainClassName} + true + ${isExecutable} + WAR + + + + + repackage + + + + + + org.apache.maven.plugins + maven-war-plugin + 2.6 + + cas + false + false + + false + ${manifestFileToUse} + + + + org.apereo.cas + cas-server-webapp${app.server} + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.3 + + + cas + + + + + + + + + true + + default + + + + + org.apereo.cas + cas-server-webapp${app.server} + ${cas.version} + war + runtime + + + ch.qos.logback + logback-classic + + + ch.qos.logback + logback-core + + + org.apache.logging.log4j + log4j-slf4j-impl + + + + + + + org.apereo.cas + cas-server-core-api-authentication + ${cas.version} + provided + + + + org.apereo.cas + cas-server-core-authentication-api + ${cas.version} + provided + + + + org.apereo.cas + cas-server-core-configuration-api + ${cas.version} + provided + + + + org.apereo.cas + cas-server-core-api-ticket + ${cas.version} + provided + + + + org.apereo.cas + cas-server-core-configuration + ${cas.version} + provided + + + + + org.apereo.cas + cas-server-core-api + ${cas.version} + provided + + + + + org.apereo.cas + cas-server-core-cookie-api + ${cas.version} + provided + + + + + org.apereo.cas + cas-server-core-services-authentication + ${cas.version} + provided + + + + + + + org.springframework.boot + spring-boot-starter-web + provided + + + ch.qos.logback + logback-classic + + + org.springframework.boot + spring-boot-starter-logging + + + + + + + org.springframework.boot + spring-boot-starter-log4j2 + ${springboot.version} + + + + + org.springframework.boot + spring-boot-starter-data-redis + provided + + + ch.qos.logback + logback-classic + + + org.springframework.boot + spring-boot-starter-logging + + + + + + + com.github.penggle + kaptcha + 2.3.2 + + + + + org.bouncycastle + bcprov-jdk15on + 1.68 + + + + + com.alibaba + fastjson + 1.2.83 + + + + + + org.apereo.cas + cas-server-support-jdbc + ${cas.version} + + + com.zaxxer + HikariCP + + + + + + org.apereo.cas + cas-server-support-jdbc-drivers + ${cas.version} + + + com.zaxxer + HikariCP + + + + + + + com.zaxxer + HikariCP + 3.2.0 + provided + + + + + mysql + mysql-connector-java + 5.1.46 + + + + + org.springframework.security + spring-security-config + + + + + org.springframework.security + spring-security-web + + + + + org.springframework.security + spring-security-crypto + 4.2.10.RELEASE + provided + + + + + org.yaml + snakeyaml + 1.17 + provided + + + + + diff --git a/src/main/java/org/apereo/cas/web/custom/auth/DbAuthenticationConfiguration.java b/src/main/java/org/apereo/cas/web/custom/auth/DbAuthenticationConfiguration.java new file mode 100644 index 0000000..8687521 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/auth/DbAuthenticationConfiguration.java @@ -0,0 +1,33 @@ +package org.apereo.cas.web.custom.auth; + +import org.apereo.cas.authentication.AuthenticationEventExecutionPlan; +import org.apereo.cas.authentication.AuthenticationEventExecutionPlanConfigurer; +import org.apereo.cas.authentication.principal.PrincipalFactory; +import org.apereo.cas.services.ServicesManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 将自定义数据库认证处理器注册进 CAS 认证执行计划, + * 使标准登录表单(/cas/login)的认证链包含数据库校验。 + */ +@Configuration("DbAuthenticationConfiguration") +public class DbAuthenticationConfiguration implements AuthenticationEventExecutionPlanConfigurer { + + @Autowired + private ServicesManager servicesManager; + + @Autowired + private PrincipalFactory principalFactory; + + @Bean + public DbAuthenticationHandler dbAuthenticationHandler() { + return new DbAuthenticationHandler("dbAuthenticationHandler", servicesManager, principalFactory); + } + + @Override + public void configureAuthenticationExecutionPlan(AuthenticationEventExecutionPlan plan) { + plan.registerAuthenticationHandler(dbAuthenticationHandler()); + } +} diff --git a/src/main/java/org/apereo/cas/web/custom/auth/DbAuthenticationHandler.java b/src/main/java/org/apereo/cas/web/custom/auth/DbAuthenticationHandler.java new file mode 100644 index 0000000..5cd2613 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/auth/DbAuthenticationHandler.java @@ -0,0 +1,118 @@ +package org.apereo.cas.web.custom.auth; + +import org.apereo.cas.authentication.AuthenticationHandlerExecutionResult; +import org.apereo.cas.authentication.UsernamePasswordCredential; +import org.apereo.cas.authentication.handler.support.AbstractUsernamePasswordAuthenticationHandler; +import org.apereo.cas.authentication.principal.PrincipalFactory; +import org.apereo.cas.services.ServicesManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import javax.annotation.PostConstruct; +import javax.security.auth.login.FailedLoginException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** + * 自定义数据库认证处理器:让 CAS 标准登录表单(/cas/login)也走数据库校验。 + * 校验逻辑与 /api/login 完全一致(BCrypt + sys_user 表),保证两套登录行为统一。 + * 用原生 DriverManager 直连,不依赖 CAS 内置 JdbcAuthenticationHandler 的连接池/模板, + * 因此不受 WAR 中 spring-jdbc 双版本等潜在初始化问题影响。 + */ +public class DbAuthenticationHandler extends AbstractUsernamePasswordAuthenticationHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(DbAuthenticationHandler.class); + + @Value("${cas.authn.jdbc.query[0].sql:select * from sys_user where username=?}") + private String sql; + @Value("${cas.authn.jdbc.query[0].url:}") + private String url; + @Value("${cas.authn.jdbc.query[0].user:}") + private String dbUser; + @Value("${cas.authn.jdbc.query[0].password:}") + private String dbPassword; + @Value("${cas.authn.jdbc.query[0].driverClass:}") + private String driverClass; + @Value("${cas.authn.jdbc.query[0].fieldPassword:password}") + private String fieldPassword; + @Value("${cas.authn.jdbc.query[0].fieldDisabled:disabled}") + private String fieldDisabled; + @Value("${cas.authn.jdbc.query[0].fieldExpired:expired}") + private String fieldExpired; + + private final BCryptPasswordEncoder bcrypt = new BCryptPasswordEncoder(); + + public DbAuthenticationHandler(String name, ServicesManager servicesManager, PrincipalFactory principalFactory) { + super(name, servicesManager, principalFactory, 0); + } + + @PostConstruct + public void init() { + if (driverClass != null && !driverClass.trim().isEmpty()) { + try { + Class.forName(driverClass); + } catch (Exception e) { + LOGGER.warn("加载 JDBC 驱动失败: {}", driverClass, e); + } + } + } + + @Override + protected AuthenticationHandlerExecutionResult authenticateUsernamePasswordInternal( + UsernamePasswordCredential credential, String transformedCredentialPassword) + throws GeneralSecurityException, org.apereo.cas.authentication.PreventedException { + final String username = credential.getUsername(); + final String password = credential.getPassword(); + if (username == null || password == null) { + throw new FailedLoginException("用户名或密码错误"); + } + try (Connection conn = DriverManager.getConnection(url, dbUser, dbPassword); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, username); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + throw new FailedLoginException("用户名或密码错误"); + } + Object disabled = rs.getObject(fieldDisabled); + if (isTruthy(disabled)) { + throw new FailedLoginException("账号已被禁用"); + } + Object expired = rs.getObject(fieldExpired); + if (isTruthy(expired)) { + throw new FailedLoginException("账号已过期"); + } + String dbPwd = rs.getString(fieldPassword); + if (dbPwd == null || !bcrypt.matches(password, dbPwd)) { + throw new FailedLoginException("用户名或密码错误"); + } + return createHandlerResult(credential, this.principalFactory.createPrincipal(username), new ArrayList<>()); + } + } catch (FailedLoginException e) { + throw e; + } catch (Exception e) { + LOGGER.error("数据库认证异常: {}", username, e); + throw new org.apereo.cas.authentication.PreventedException("数据库认证异常", e); + } + } + + private boolean isTruthy(Object value) { + if (value == null) { + return false; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value instanceof Number) { + return ((Number) value).intValue() != 0; + } + String text = String.valueOf(value).trim(); + return "1".equals(text) || "true".equalsIgnoreCase(text) + || "y".equalsIgnoreCase(text) || "yes".equalsIgnoreCase(text); + } +} diff --git a/src/main/java/org/apereo/cas/web/custom/config/RSAUtils.java b/src/main/java/org/apereo/cas/web/custom/config/RSAUtils.java new file mode 100644 index 0000000..4b25066 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/config/RSAUtils.java @@ -0,0 +1,92 @@ +package org.apereo.cas.web.custom.config; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +import javax.crypto.Cipher; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Security; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +public class RSAUtils { + + public static final String KEY_ALGORITHM = "RSA"; + public static final String SIGNATURE_ALGORITHM = "MD5withRSA"; + private static final String PUBLIC_KEY = "RSAPublicKey"; + private static final String PRIVATE_KEY = "RSAPrivateKey"; + private static final int KEY_SIZE = 2048; + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + /** + * 生成 RSA 密钥对 + */ + public static Map generateKeyPair() throws Exception { + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(KEY_ALGORITHM); + keyPairGen.initialize(KEY_SIZE); + KeyPair keyPair = keyPairGen.generateKeyPair(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + Map keyMap = new HashMap<>(); + keyMap.put(PUBLIC_KEY, Base64.getEncoder().encodeToString(publicKey.getEncoded())); + keyMap.put(PRIVATE_KEY, Base64.getEncoder().encodeToString(privateKey.getEncoded())); + return keyMap; + } + + /** + * 公钥加密 + */ + public static String encryptByPublicKey(String data, String publicKey) throws Exception { + byte[] keyBytes = Base64.getDecoder().decode(normalizeKeyBase64(publicKey)); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); + RSAPublicKey pubKey = (RSAPublicKey) keyFactory.generatePublic(keySpec); + Cipher cipher = Cipher.getInstance(KEY_ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, pubKey); + byte[] encrypted = cipher.doFinal(data.getBytes("UTF-8")); + return Base64.getEncoder().encodeToString(encrypted); + } + + /** + * 私钥解密 + */ + public static String decryptByPrivateKey(String data, String privateKey) throws Exception { + byte[] keyBytes = Base64.getDecoder().decode(normalizeKeyBase64(privateKey)); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); + RSAPrivateKey priKey = (RSAPrivateKey) keyFactory.generatePrivate(keySpec); + Cipher cipher = Cipher.getInstance(KEY_ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, priKey); + byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(normalizeCipherBase64(data))); + return new String(decrypted, "UTF-8"); + } + + private static String normalizeKeyBase64(String base64) { + if (base64 == null) { + return ""; + } + return base64 + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + } + + private static String normalizeCipherBase64(String base64) { + if (base64 == null) { + return ""; + } + // GET 参数中的 '+' 可能会被解码成空格,这里兼容这种情况。 + return base64.replace(' ', '+').replaceAll("\\s+", ""); + } +} diff --git a/src/main/java/org/apereo/cas/web/custom/config/RedisConfig.java b/src/main/java/org/apereo/cas/web/custom/config/RedisConfig.java new file mode 100644 index 0000000..cd7310b --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/config/RedisConfig.java @@ -0,0 +1,40 @@ +package org.apereo.cas.web.custom.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + @Value("${spring.redis.host:localhost}") + private String host; + + @Value("${spring.redis.port:6379}") + private int port; + + @Value("${spring.redis.password:}") + private String password; + + @Value("${spring.redis.database:0}") + private int database; + + @Bean + public JedisConnectionFactory redisConnectionFactory() { + JedisConnectionFactory factory = new JedisConnectionFactory(); + factory.setHostName(host); + factory.setPort(port); + factory.setDatabase(database); + if (password != null && !password.trim().isEmpty()) { + factory.setPassword(password); + } + return factory; + } + + @Bean + public StringRedisTemplate stringRedisTemplate(JedisConnectionFactory redisConnectionFactory) { + return new StringRedisTemplate(redisConnectionFactory); + } +} 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 new file mode 100644 index 0000000..ac86bb6 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/controller/LoginController.java @@ -0,0 +1,579 @@ +package org.apereo.cas.web.custom.controller; + +import com.alibaba.fastjson.JSONObject; +import org.apereo.cas.web.custom.service.LoginService; +import org.apereo.cas.web.custom.config.RSAUtils; +import org.apereo.cas.web.custom.utils.CaptchaUtils; +import org.apereo.cas.CentralAuthenticationService; +import org.apereo.cas.authentication.Authentication; +import org.apereo.cas.authentication.AuthenticationException; +import org.apereo.cas.authentication.AuthenticationResult; +import org.apereo.cas.authentication.DefaultAuthenticationBuilder; +import org.apereo.cas.authentication.DefaultAuthenticationResultBuilder; +import org.apereo.cas.authentication.DefaultPrincipalElectionStrategy; +import org.apereo.cas.authentication.AuthenticationSystemSupport; +import org.apereo.cas.authentication.Credential; +import org.apereo.cas.authentication.UsernamePasswordCredential; +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; +import org.apereo.cas.web.support.CookieRetrievingCookieGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.web.bind.annotation.*; + +import javax.imageio.ImageIO; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.net.URLEncoder; +import java.net.URLDecoder; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +@RestController +@RequestMapping("/api") +public class LoginController { + + private static final Logger logger = LoggerFactory.getLogger(LoginController.class); + + @Autowired + private LoginService loginService; + + @Autowired + private CentralAuthenticationService centralAuthenticationService; + + @Autowired + private AuthenticationSystemSupport authenticationSystemSupport; + + @Autowired + @Qualifier("ticketGrantingTicketCookieGenerator") + private CookieRetrievingCookieGenerator ticketGrantingTicketCookieGenerator; + + @Value("${custom.login.default-redirect-url}") + private String defaultRedirectUrl; + + // 是否强制每次登录都必须校验验证码。默认 false = 维持原逻辑(仅会话失败达到阈值后才要求); + // 设为 true 时,任何登录(含首次)都必须携带并校验验证码,用于彻底阻断批量账号枚举探测。 + @Value("${custom.login.always-captcha:false}") + private boolean alwaysCaptcha; + + @Value("${custom.rsa.private-key:}") + private String rsaPrivateKey; + + private final BCryptPasswordEncoder bcryptEncoder = new BCryptPasswordEncoder(); + + // 账号不存在/禁用/过期等“提前返回”分支原本不会经过 BCrypt,导致响应时长明显短于 + // “账号存在但密码错误”(会执行一次 BCrypt 约 100ms)。攻击者可用时长差枚举账号。 + // 这里用一份固定的 BCrypt 哈希做“空跑”,对齐各分支耗时,消除时序侧信道。 + private static final String DUMMY_BCRYPT_HASH = + "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZd9qJ6jFm"; + + @Value("${custom.rsa.public-key:}") + private String rsaPublicKey; + + @Value("${cas.authn.jdbc.query[0].sql:select * from sys_user where username=?}") + private String jdbcAuthSql; + + @Value("${cas.authn.jdbc.query[0].url:}") + private String jdbcUrl; + + @Value("${cas.authn.jdbc.query[0].user:}") + private String jdbcUser; + + @Value("${cas.authn.jdbc.query[0].password:}") + private String jdbcPassword; + + @Value("${cas.authn.jdbc.query[0].driverClass:}") + private String jdbcDriverClass; + + @Value("${cas.authn.jdbc.query[0].fieldPassword:password}") + private String jdbcPasswordField; + + @Value("${cas.authn.jdbc.query[0].fieldDisabled:disabled}") + private String jdbcDisabledField; + + @Value("${cas.authn.jdbc.query[0].fieldExpired:expired}") + private String jdbcExpiredField; + + @PostMapping("/login") + public void login( + @RequestParam("loginname") String encryptedLoginName, + @RequestParam("pwd") String encryptedPassword, + @RequestParam(value = "captcha", required = false) String captchaText, + @RequestParam(value = "service", required = false) String service, + 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); + } + } + + HttpSession session = request.getSession(); + + try { + if (rsaPrivateKey == null || rsaPrivateKey.trim().isEmpty()) { + respondError(request, response, "RSA私钥未配置"); + return; + } + + String loginName = RSAUtils.decryptByPrivateKey(encryptedLoginName, rsaPrivateKey); + String password = RSAUtils.decryptByPrivateKey(encryptedPassword, rsaPrivateKey); + + logger.info("用户 {} 尝试登录,Session ID: {}", loginName, session.getId()); + + if (loginService.isAccountLocked(loginName)) { + respondError(request, response, + "账号已被锁定,请" + loginService.getAccountLockRemainingText(loginName) + "后重试"); + return; + } + + if (alwaysCaptcha || loginService.needCaptcha(session)) { + if (captchaText == null || captchaText.trim().isEmpty()) { + respondError(request, response, "请输入验证码", true); + return; + } + if (!loginService.verifyCaptcha(session, captchaText)) { + respondError(request, response, "验证码错误", true); + return; + } + } + + LocalAuthenticationResult authResult = authenticate(loginName, password); + + if (authResult.success) { + loginService.clearSessionFail(session); + loginService.clearAccountFail(loginName); + + logger.info("用户 {} 登录成功", loginName); + + // 建立真正的 SSO 会话:创建 TGT 并下发 TGC Cookie + String ssoUrl = establishSsoSession(loginName, password, service, request, response); + if (ssoUrl == null) { + respondError(request, response, "登录成功,但建立会话失败,请重试"); + return; + } + // 成功:浏览器原生表单提交场景下用 302 跳转(同时让浏览器记住密码); + // AJAX 调用(带 X-Requested-With)仍返回 JSON 由前端手动跳转。 + respondSuccess(request, response, ssoUrl); + return; + } + + if (!authResult.countFailure) { + // 非凭证类失败(账号禁用/过期/系统异常等):不计入失败次数与锁定, + // 但对外提示与“用户名或密码错误”完全一致,避免攻击者借返回差异枚举账号。 + respondError(request, response, "用户名或密码错误", alwaysCaptcha); + return; + } + + // 凭证类失败(账号不存在/密码错误):计入失败次数与锁定。统一只返回“用户名或密码错误”, + // 不回显“剩余尝试次数”,否则又会因文案长短差异暴露账号是否存在。 + int sessionFailCount = loginService.recordSessionFail(session); + boolean accountLocked = loginService.recordAccountFail(loginName); + + logger.warn("用户 {} 登录失败,Session 失败次数: {}, 账号锁定: {}", loginName, sessionFailCount, accountLocked); + + if (accountLocked) { + respondError(request, response, + "账号已锁定,请" + loginService.getAccountLockRemainingText(loginName) + "后重试"); + return; + } + + respondError(request, response, "用户名或密码错误", sessionFailCount >= 2 || alwaysCaptcha); + } catch (Exception e) { + String msg = e.getMessage() == null ? "登录失败" : e.getMessage(); + try { + respondError(request, response, msg); + } catch (Exception ignored) { + } + } + } + + /** + * 获取验证码(图片) + */ + @GetMapping(value = { "/captcha" }, produces = "image/png") + public void getCaptcha( + @RequestParam(value = "k", required = false) String k, + HttpServletRequest request, + HttpServletResponse response) throws IOException { + HttpSession session = request.getSession(); + + String captchaText = CaptchaUtils.generateText(); + loginService.saveCaptcha(session, captchaText); + + BufferedImage image = CaptchaUtils.generateImage(captchaText); + response.setContentType("image/png"); + response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); + response.setHeader("Pragma", "no-cache"); + response.setDateHeader("Expires", 0L); + ImageIO.write(image, "png", response.getOutputStream()); + response.flushBuffer(); + } + + @GetMapping("/need-captcha") + public JSONObject needCaptcha(HttpServletRequest request) { + HttpSession session = request.getSession(); + JSONObject data = new JSONObject(); + data.put("needCaptcha", alwaysCaptcha || loginService.needCaptcha(session)); + 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()) { + return buildResponse(1, new JSONObject(), "RSA公钥未配置"); + } + rsaPublicKey = rsaPublicKey.replaceAll("\\s+", ""); + JSONObject data = new JSONObject(); + data.put("publicKey", rsaPublicKey); + return buildResponse(0, data, ""); + } + + private JSONObject buildResponse(int code, JSONObject data, String msg) { + JSONObject result = new JSONObject(); + result.put("code", code); + result.put("data", data == null ? new JSONObject() : data); + result.put("msg", msg); + return result; + } + + private String toPem(String type, String base64) { + String b64 = base64 == null ? "" : base64.replaceAll("\\s+", ""); + StringBuilder sb = new StringBuilder(); + sb.append("-----BEGIN ").append(type).append("-----\n"); + for (int i = 0; i < b64.length(); i += 64) { + int end = Math.min(i + 64, b64.length()); + sb.append(b64, i, end).append('\n'); + } + sb.append("-----END ").append(type).append("-----"); + return sb.toString(); + } + + private void writeJson(HttpServletResponse response, JSONObject json) throws IOException { + response.setStatus(HttpServletResponse.SC_OK); + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(json.toJSONString()); + response.flushBuffer(); + } + + private boolean isAjax(HttpServletRequest request) { + return "XMLHttpRequest".equalsIgnoreCase(request.getHeader("X-Requested-With")) + || "application/json".equalsIgnoreCase(request.getHeader("Accept")); + } + + private void respondSuccess(HttpServletRequest request, HttpServletResponse response, String url) throws IOException { + // 始终返回 JSON {code:0, data:{url}},由前端 JS 读取 data.url 后手动跳转(满足“接口返回地址、JS 手动跳转”)。 + JSONObject data = new JSONObject(); + data.put("url", url); + writeJson(response, buildResponse(0, data, null)); + } + + private void respondError(HttpServletRequest request, HttpServletResponse response, String msg) throws IOException { + respondError(request, response, msg, false); + } + + private void respondError(HttpServletRequest request, HttpServletResponse response, String msg, boolean needCaptcha) + throws IOException { + if (isAjax(request)) { + JSONObject data = new JSONObject(); + if (needCaptcha) { + data.put("needCaptcha", true); + } + writeJson(response, buildResponse(1, data, msg)); + } else { + // 原生表单提交:302 回登录页并带错误提示;同时让浏览器在成功时记住密码。 + String target = "/cas/login?error=" + URLEncoder.encode(msg, "UTF-8"); + response.sendRedirect(target); + } + } + + /** + * 自定义认证逻辑 + */ + private LocalAuthenticationResult authenticate(String username, String password) { + if (jdbcUrl == null || jdbcUrl.trim().isEmpty()) { + logger.error("用户 {} 登录失败:未配置JDBC认证地址", username); + return LocalAuthenticationResult.failure(false); + } + try { + if (jdbcDriverClass != null && !jdbcDriverClass.trim().isEmpty()) { + Class.forName(jdbcDriverClass); + } + try (Connection connection = DriverManager.getConnection(jdbcUrl, jdbcUser, jdbcPassword); + PreparedStatement statement = connection.prepareStatement(jdbcAuthSql)) { + statement.setString(1, username); + try (ResultSet rs = statement.executeQuery()) { + if (!rs.next()) { + // 时序对齐:账号不存在也空跑一次 BCrypt,避免响应时长差异被利用枚举账号 + bcryptEncoder.matches(password, DUMMY_BCRYPT_HASH); + logger.info("用户 {} 登录失败:账号不存在或密码错误", username); + return LocalAuthenticationResult.failure(true); + } + Object disabledValue = rs.getObject(jdbcDisabledField); + if (isTruthy(disabledValue)) { + // 时序对齐 + bcryptEncoder.matches(password, DUMMY_BCRYPT_HASH); + logger.info("用户 {} 登录失败:账号状态异常(禁用/过期等)", username); + return LocalAuthenticationResult.failure(false); + } + Object expiredValue = rs.getObject(jdbcExpiredField); + if (isTruthy(expiredValue)) { + // 时序对齐 + bcryptEncoder.matches(password, DUMMY_BCRYPT_HASH); + logger.info("用户 {} 登录失败:账号状态异常(禁用/过期等)", username); + return LocalAuthenticationResult.failure(false); + } + String dbPassword = rs.getString(jdbcPasswordField); + boolean matched; + if (dbPassword == null) { + matched = false; + } else { + try { + matched = bcryptEncoder.matches(password, dbPassword); + } catch (Exception ex) { + // 库中密码不是合法 BCrypt 哈希(例如仍是明文)时视为不匹配, + // 避免抛出“认证服务异常”掩盖真实问题 + logger.warn("账号 {} 的密码不是合法 BCrypt 哈希,请确认已用 BCrypt 加密存储", username); + matched = false; + } + } + if (matched) { + return LocalAuthenticationResult.success(); + } + logger.info("用户 {} 登录失败:账号不存在或密码错误", username); + return LocalAuthenticationResult.failure(true); + } + } + } catch (Exception e) { + logger.error("用户 {} JDBC认证异常", username, e); + return LocalAuthenticationResult.failure(false); + } + } + + private boolean isTruthy(Object value) { + if (value == null) { + return false; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value instanceof Number) { + return ((Number) value).intValue() != 0; + } + String text = String.valueOf(value).trim(); + return "1".equals(text) || "true".equalsIgnoreCase(text) || "y".equalsIgnoreCase(text) + || "yes".equalsIgnoreCase(text); + } + + /** + * 建立真正的 SSO 会话:基于自定义登录已校验过的身份直接签发 TGT 并下发 TGC Cookie。 + * 密码已在 authenticate() 中用 BCrypt 校验通过,这里不再走 CAS 的认证 handler 链 + * (否则会触发 CAS 用自己的密码编码器重复校验而失败),而是直接构造已认证的 Authentication。 + * 返回应跳转的地址(带 ticket 的 service 或默认地址);失败返回 null。 + */ + private String establishSsoSession(String username, String password, String service, + HttpServletRequest request, HttpServletResponse response) { + try { + Principal principal = new DefaultPrincipalFactory().createPrincipal(username); + Authentication authentication = new DefaultAuthenticationBuilder(principal).build(); + + DefaultAuthenticationResultBuilder resultBuilder = new DefaultAuthenticationResultBuilder(); + resultBuilder.collect(authentication); + + Service svc = (service != null && !service.trim().isEmpty()) + ? new WebApplicationServiceFactory().createService(service.trim()) + : null; + + AuthenticationResult authResult = (svc != null) + ? resultBuilder.build(new DefaultPrincipalElectionStrategy(), svc) + : resultBuilder.build(new DefaultPrincipalElectionStrategy()); + + // 创建 TGT(不再重新校验密码) + TicketGrantingTicket tgt = centralAuthenticationService.createTicketGrantingTicket(authResult); + // 下发 TGC Cookie,浏览器凭此在访问其他接入 CAS 的应用时实现单点登录 + ticketGrantingTicketCookieGenerator.addCookie(request, response, tgt.getId()); + + if (svc != null) { + // 为有 service 的接入方签发 ServiceTicket 并带 ticket 跳转,完成单点登录 + ServiceTicket st = centralAuthenticationService.grantServiceTicket(tgt.getId(), svc, authResult); + String svcUrl = svc.getId(); + return svcUrl + (svcUrl.contains("?") ? "&" : "?") + "ticket=" + st.getId(); + } + // 返回绝对 URL,确保前端无论从哪个 origin 打开都能正确跳回 CAS 同源, + // 从而把 TGC Cookie 一起带过去(相对路径 /cas/login 在非同源前端下会跳错 origin)。 + return buildDefaultFallbackUrl(request); + } catch (AuthenticationException | AbstractTicketException e) { + logger.error("用户 {} 建立 SSO 会话失败", username, e); + return null; + } catch (Exception e) { + logger.error("用户 {} 建立 SSO 会话异常", username, e); + return null; + } + } + + /** + * 构造默认落点(无 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 无法随跳转携带。 + */ + private String buildCasAbsoluteUrl(HttpServletRequest request, String relativePath) { + // 若已是绝对地址(如 Nacos 配置的默认跳转站点 http(s)://...),直接返回, + // 避免被拼到 CAS 自身 host 前面变成 https://cas-host:8443/http://xxx 这类错误地址。 + if (relativePath != null + && (relativePath.startsWith("http://") || relativePath.startsWith("https://"))) { + return relativePath; + } + String scheme = request.getScheme(); + String host = request.getServerName(); + int port = request.getServerPort(); + StringBuilder sb = new StringBuilder(); + sb.append(scheme).append("://").append(host); + boolean needPort = (scheme.equalsIgnoreCase("http") && port != 80) + || (scheme.equalsIgnoreCase("https") && port != 443); + if (needPort) { + sb.append(":").append(port); + } + if (relativePath == null || relativePath.isEmpty()) { + return sb.toString(); + } + if (!relativePath.startsWith("/")) { + sb.append("/"); + } + sb.append(relativePath); + return sb.toString(); + } + + private static final class LocalAuthenticationResult { + private final boolean success; + private final boolean countFailure; + + private LocalAuthenticationResult(boolean success, boolean countFailure) { + this.success = success; + this.countFailure = countFailure; + } + + private static LocalAuthenticationResult success() { + return new LocalAuthenticationResult(true, false); + } + + // countFailure=true 表示应计入失败次数与锁定(账号不存在/密码错误等凭证类失败); + // false 表示不计入(账号禁用/过期/系统异常等)。两类失败对客户端统一返回“用户名或密码错误”。 + private static LocalAuthenticationResult failure(boolean countFailure) { + return new LocalAuthenticationResult(false, countFailure); + } + } + +} diff --git a/src/main/java/org/apereo/cas/web/custom/service/LoginService.java b/src/main/java/org/apereo/cas/web/custom/service/LoginService.java new file mode 100644 index 0000000..f9cd273 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/service/LoginService.java @@ -0,0 +1,181 @@ +package org.apereo.cas.web.custom.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import javax.servlet.http.HttpSession; +import java.util.concurrent.TimeUnit; + +@Service +public class LoginService { + + @Autowired + private StringRedisTemplate redisTemplate; + + // ==================== Redis 相关(账号级别) ==================== + private static final String LOCK_KEY_PREFIX = "login:lock:"; + private static final String ACCOUNT_FAIL_KEY_PREFIX = "login:account:fail:"; + + // ==================== Session 相关(浏览器级别) ==================== + private static final String SESSION_FAIL_KEY = "SESSION_LOGIN_FAIL_COUNT"; + + // 阀值 + private static final int CAPTCHA_THRESHOLD = 2; // Session 失败 >= 2 次,需要验证码 + private static final int LOCK_THRESHOLD = 5; // 账号累计失败 5 次,锁定 10 分钟 + private static final int LOCK_DURATION = 10; // 锁定 10 分钟(单位:分钟) + + // ==================== 账号级别(Redis) ==================== + + /** + * 检查账号是否被锁定(Redis) + */ + public boolean isAccountLocked(String loginName) { + String lockKey = LOCK_KEY_PREFIX + loginName; + return Boolean.TRUE.equals(redisTemplate.hasKey(lockKey)); + } + + public long getAccountLockRemainingSeconds(String loginName) { + String lockKey = LOCK_KEY_PREFIX + loginName; + Long seconds = redisTemplate.getExpire(lockKey, TimeUnit.SECONDS); + if (seconds == null || seconds < 0) { + return 0L; + } + return seconds; + } + + public String getAccountLockRemainingText(String loginName) { + long seconds = getAccountLockRemainingSeconds(loginName); + if (seconds >= 60) { + return (seconds / 60) + "分钟"; + } + if (seconds > 0) { + return seconds + "秒"; + } + return "0秒"; + } + + /** + * 获取账号累计失败次数(Redis) + */ + public int getAccountFailCount(String loginName) { + String failKey = ACCOUNT_FAIL_KEY_PREFIX + loginName; + String count = redisTemplate.opsForValue().get(failKey); + return count == null ? 0 : Integer.parseInt(count); + } + + /** + * 记录账号失败(Redis),如果达到 5 次则锁定 + * + * @return 是否达到锁定阈值 + */ + public boolean recordAccountFail(String loginName) { + String failKey = ACCOUNT_FAIL_KEY_PREFIX + loginName; + String lockKey = LOCK_KEY_PREFIX + loginName; + + // 如果已经锁定,直接返回 true + if (Boolean.TRUE.equals(redisTemplate.hasKey(lockKey))) { + return true; + } + + // 增加失败次数 + Long failCount = redisTemplate.opsForValue().increment(failKey, 1L); + if (failCount == 1L) { + // 第一次失败,设置 24 小时过期(窗口期) + redisTemplate.expire(failKey, 24, TimeUnit.HOURS); + } + + // 达到 5 次,锁定账号 10 分钟 + if (failCount != null && failCount >= LOCK_THRESHOLD) { + long unlockAt = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(LOCK_DURATION); + redisTemplate.opsForValue().set(lockKey, String.valueOf(unlockAt), LOCK_DURATION, TimeUnit.MINUTES); + // 清除失败计数(锁释放后重新计数) + redisTemplate.delete(failKey); + return true; + } + return false; + } + + /** + * 登录成功,清除账号失败计数和锁定(Redis) + */ + public void clearAccountFail(String loginName) { + String failKey = ACCOUNT_FAIL_KEY_PREFIX + loginName; + String lockKey = LOCK_KEY_PREFIX + loginName; + redisTemplate.delete(failKey); + redisTemplate.delete(lockKey); + } + + // ==================== Session 级别 ==================== + + /** + * 获取 Session 失败次数 + */ + public int getSessionFailCount(HttpSession session) { + Integer count = (Integer) session.getAttribute(SESSION_FAIL_KEY); + return count == null ? 0 : count; + } + + /** + * 记录 Session 失败次数 + * + * @return 当前 Session 失败次数 + */ + public int recordSessionFail(HttpSession session) { + int failCount = getSessionFailCount(session) + 1; + session.setAttribute(SESSION_FAIL_KEY, failCount); + return failCount; + } + + /** + * 清除 Session 失败计数(登录成功后调用) + */ + public void clearSessionFail(HttpSession session) { + session.removeAttribute(SESSION_FAIL_KEY); + } + + /** + * 判断当前 Session 是否需要验证码(失败 >= 3 次) + */ + public boolean needCaptcha(HttpSession session) { + return getSessionFailCount(session) >= CAPTCHA_THRESHOLD; + } + + /** + * 保存验证码到 Session + */ + public void saveCaptcha(HttpSession session, String captchaText) { + session.setAttribute("CAPTCHA_TEXT", captchaText); + // 设置 5 分钟过期(Session 本身会过期,这里额外控制) + } + + /** + * 校验验证码(从 Session 中对比) + */ + public boolean verifyCaptcha(HttpSession session, String captchaText) { + String savedText = (String) session.getAttribute("CAPTCHA_TEXT"); + if (savedText == null) { + return false; + } + if (captchaText == null) { + session.removeAttribute("CAPTCHA_TEXT"); + return false; + } + // 验证码一次性使用,校验后清除 + session.removeAttribute("CAPTCHA_TEXT"); + return savedText.equalsIgnoreCase(captchaText); + } + + /** + * 综合判断:是否需要验证码(Session 失败 >= 3 次) + * 如果账号已锁定,也返回 true(但 controller 会先检查锁定) + */ + public boolean needCaptcha(HttpSession session, String loginName) { + // Session 级别失败 >= 3 次,需要验证码 + if (getSessionFailCount(session) >= CAPTCHA_THRESHOLD) { + return true; + } + // 账号被锁定也需要验证码(但 controller 会直接拒绝) + return isAccountLocked(loginName); + } +} diff --git a/src/main/java/org/apereo/cas/web/custom/utils/CaptchaUtils.java b/src/main/java/org/apereo/cas/web/custom/utils/CaptchaUtils.java new file mode 100644 index 0000000..f5d07c8 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/utils/CaptchaUtils.java @@ -0,0 +1,68 @@ +package org.apereo.cas.web.custom.utils; + +import com.google.code.kaptcha.Producer; +import com.google.code.kaptcha.impl.DefaultKaptcha; +import com.google.code.kaptcha.util.Config; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.util.Base64; +import java.util.Properties; + +public class CaptchaUtils { + + private static final Logger logger = LoggerFactory.getLogger(CaptchaUtils.class); + + private static final Producer KAPTCHA_PRODUCER; + + static { + DefaultKaptcha defaultKaptcha = new DefaultKaptcha(); + Properties properties = new Properties(); + properties.setProperty("kaptcha.border", "no"); + properties.setProperty("kaptcha.textproducer.font.color", "black"); + properties.setProperty("kaptcha.textproducer.char.space", "5"); + properties.setProperty("kaptcha.textproducer.char.length", "4"); + properties.setProperty("kaptcha.textproducer.font.size", "30"); + properties.setProperty("kaptcha.image.width", "120"); + properties.setProperty("kaptcha.image.height", "40"); + properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.DefaultNoise"); + properties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.WaterRipple"); + Config config = new Config(properties); + defaultKaptcha.setConfig(config); + KAPTCHA_PRODUCER = defaultKaptcha; + } + + /** + * 生成验证码文本 + */ + public static String generateText() { + return KAPTCHA_PRODUCER.createText(); + } + + /** + * 生成验证码图片 + */ + public static BufferedImage generateImage(String text) { + return KAPTCHA_PRODUCER.createImage(text); + } + + /** + * BufferedImage 转 Base64 PNG + */ + public static String imageToBase64(BufferedImage image) { + try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", os); + return "data:image/png;base64," + Base64.getEncoder().encodeToString(os.toByteArray()); + } catch (Exception e) { + logger.error("验证码图片转Base64失败", e); + return ""; + } + } + + private CaptchaUtils() { + // 工具类禁止实例化 + } +} \ No newline at end of file diff --git a/src/main/java/org/apereo/cas/web/custom/utils/PasswordHashUtil.java b/src/main/java/org/apereo/cas/web/custom/utils/PasswordHashUtil.java new file mode 100644 index 0000000..ee477f3 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/utils/PasswordHashUtil.java @@ -0,0 +1,25 @@ +package org.apereo.cas.web.custom.utils; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +/** + * BCrypt 密码哈希生成工具。 + * 用于把数据库里现有的明文密码批量/手动替换为 BCrypt 哈希。 + * + * 用法(IDEA 直接 Run 此 main,或命令行): + * java -cp "<编译输出目录>;" org.apereo.cas.web.custom.utils.PasswordHashUtil 123456 + * 可一次传入多个明文,逐行输出 明文 -> 哈希 + */ +public class PasswordHashUtil { + + public static void main(String[] args) { + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + if (args == null || args.length == 0) { + System.out.println("用法: PasswordHashUtil <明文密码> [明文密码2 ...]"); + return; + } + for (String raw : args) { + System.out.println(raw + " -> " + encoder.encode(raw)); + } + } +} diff --git a/src/main/resources/application.properties.txt b/src/main/resources/application.properties.txt new file mode 100644 index 0000000..f7ad064 --- /dev/null +++ b/src/main/resources/application.properties.txt @@ -0,0 +1,60 @@ +# ==================== ½ûÓà HTTPS£¨¿ª·¢»·¾³£© ==================== +server.ssl.enabled=false +server.port=8443 + +# ==================== CAS ºËÐÄÅäÖà ==================== +# ÔÊÐíͨ¹ý HTTP ´«Êä TGC Cookie +cas.tgc.secure=false +# ÏÔʽָ¶¨ TGC Cookie µÄ Path Ϊ /cas£»·ñÔòĬÈÏÁô¿գ¬ä¯ÀÀÆ÷°´¡°ÇëÇóĿ¼¡±ȡֵ£¬ +# ×Զ¨ÒåµÇ¼ÔÚ /cas/api/login ÖÖ Cookie »á±ä³É Path=/cas/api/£¬Ìøµ½ /cas/login ʱä¯ÀÀÆ÷²»´ø´Ë Cookie£¬µ¼Ö»¹ҪÔٵǼ +cas.tgc.path=/cas + +# ´Ó JSON Îļþ¼ÓÔطþÎñע²áÐÅϢ +cas.serviceRegistry.initFromJson=true + +# ==================== ÈÏ֤ÅäÖà ==================== +# ¾²̬Óû§Áбí£¨¸ñʽ£ºusername::password£© +cas.authn.accept.users=casuser::Mellon + +# ==================== ÈÕ־ÅäÖ㨿Éѡ£© ==================== +# ÉèÖÃÈÕ־¼¶±𣬱ãÓڵ÷ÊÔ +logging.level.org.apereo.cas=DEBUG + +# ==================== Redis ÅäÖã¨Spring Boot ±ê׼ÅäÖã© ==================== + +spring.redis.host=localhost +spring.redis.port=6379 +spring.redis.password=123456 +spring.redis.database=0 +spring.redis.timeout=5000 +spring.redis.jedis.pool.max-active=8 +spring.redis.jedis.pool.max-idle=8 +spring.redis.jedis.pool.min-idle=0 + + + +# ==================== JDBC ÈÏ֤ÅäÖà ==================== +# ²éѯ SQL£¬¸ù¾ÝÓû§Ãû»ñȡÓû§ÐÅϢ +cas.authn.jdbc.query[0].sql=select * from sys_user where username=? +# ÃÜÂë×ֶÎÃû +cas.authn.jdbc.query[0].fieldPassword=password +# Õ˺ŽûÓÃ×ֶΣ¨¿Éѡ£¬1±íʾ½ûÓã© +cas.authn.jdbc.query[0].fieldDisabled=disabled +# ÃÜÂë¹ýÆÚ×ֶΣ¨¿Éѡ£¬1±íʾ¹ýÆڣ© +cas.authn.jdbc.query[0].fieldExpired=expired + +# Êý¾ݿâÁ¬½ÓÐÅϢ +cas.authn.jdbc.query[0].url=jdbc:mysql://127.0.0.1:3306/yunhai_base?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai +cas.authn.jdbc.query[0].user=root +cas.authn.jdbc.query[0].password=123456 +cas.authn.jdbc.query[0].driverClass=com.mysql.jdbc.Driver +# Êý¾ݿⷽÑÔ +cas.authn.jdbc.query[0].dialect=org.hibernate.dialect.MySQLDialect + +# ==================== ÃÜÂë¼ÓÃܲßÂԣ¨¸ù¾Ýʵ¼ÊÇé¿öÅäÖã© ==================== +# Èç¹ûÊý¾ݿâÃÜÂëÊÇÃ÷Îģ¬ÔòÉèΪ NONE +cas.authn.jdbc.query[0].passwordEncoder.type=NONE +# Èç¹ûÊÇ MD5 ¼ÓÃܣ¬ÔòʹÓÃÒÔÏÂÅäÖà +# cas.authn.jdbc.query[0].passwordEncoder.type=DEFAULT +# cas.authn.jdbc.query[0].passwordEncoder.encodingAlgorithm=MD5 +#cas.authn.sso.excluded-paths=/api/** diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..d814fbb --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,113 @@ +server: + ssl: + enabled: false + port: 8443 + use-forward-headers: false + +cas: + # 显式声明 CAS 自身访问地址,避免根路径 / 或 /index.html 被重定向到错误端口(曾出现跳到 8080 的问题)。 + # CAS 的默认值是 https://cas.example.org:8443,若运行时被外部配置覆盖成 8080,根页面重定向就会错位。 + server: + name: http://localhost:8443 + prefix: http://localhost:8443/cas + # CAS 核心配置 + tgc: + secure: false + # 显式指定 TGC Cookie 的 Path,必须覆盖 /cas 下所有路径(含自定义 /cas/api/login)。 + # 若不配置,CAS 默认会把 Path 留空,浏览器按“请求目录”取值: + # - 标准登录在 /cas/login 种 Cookie -> Path=/cas/ -> 正常 + # - 自定义登录在 /cas/api/login 种 Cookie -> Path=/cas/api/ -> 跳到 /cas/login 时浏览器不带此 Cookie,导致还要再登录 + path: /cas + # 固定 TGC 加密/签名密钥。不配置的话每次重启都会自动生成新密钥, + # 导致浏览器里旧 TGC Cookie 解密失败(表现为启动后首次访问的 NPE DEBUG 噪声),且启动日志反复 WARN。 + crypto: + encryption: + key: 7qLRih0bq6I_A7MehkJVtD4c9foKUFUgU0Py8BvmX9w + signing: + key: RVYx5M5u-Wu6WPNz1C-PSpCDPlnJ7Z9rvaVfaWLJlbddhcKnUhiluW4Z2S4XgnV3ya6DydtBeS55cnKwFkgzZw + # 固定 Webflow 加密/签名密钥,理由同上 + webflow: + crypto: + encryption: + key: VgGmJuzf2Xbk2Pn0ZrEMVw + signing: + key: 48ES4odoN40R9JPlIIcxbOWCHBngCTolqEypXuS86WUjNa1eccI6_f7e3GoQur04ki9Tu7PbFs1XO3FdN1Q_Mw + serviceRegistry: + initFromJson: true + # 登出后允许按 ?service= 参数跳转到指定地址(如回到登录页) + logout: + followServiceRedirects: true + # 认证配置 + authn: + # JDBC 认证配置 + jdbc: + query[0]: + sql: select * from sys_user where username=? + fieldPassword: password + fieldDisabled: disabled + fieldExpired: expired + url: jdbc:mysql://localhost:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai + user: root + password: 123456 + driverClass: com.mysql.jdbc.Driver + dialect: org.hibernate.dialect.MySQLDialect + # 密码加密策略:与自定义登录保持一致,使用 BCrypt(strength 10) + # 这样 establishSsoSession 调用 CAS 认证链重新校验密码时也能通过 + passwordEncoder: + type: BCRYPT + strength: 10 + +spring: + # 排除 Spring Boot 的 SecurityAutoConfiguration。 + # 原因:overlay 的 pom 把 spring-security-config/web 设成 compile 进了 war, + # 而 CasWebApplication 的 @SpringBootApplication(exclude=...) 里并未排除 SecurityAutoConfiguration + # (CAS 原本不依赖 spring-security-config,webapp 不自带它)。 + # 一旦 spring-security-config 在 classpath,SecurityAutoConfiguration 就会激活, + # 注册默认 SecurityFilterChain 拦截 /cas/login,用默认登录页 + 默认凭据(user/随机密码)接管认证, + # 导致 CAS webflow 的 accept-users/JDBC handler 完全失效,登录必报 "Reason: none"。 + # 排除后没有任何 Spring Security filter chain,/cas/login 由 CAS webflow 正常处理。 + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.security.SecurityAutoConfiguration + - org.springframework.boot.autoconfigure.security.SecurityFilterAutoConfiguration + - org.springframework.boot.actuate.autoconfigure.ManagementWebSecurityAutoConfiguration + redis: + host: localhost + port: 6379 + password: 123456 + database: 0 + timeout: 5000 + jedis: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + +logging: + level: + # 排查问题时可临时改为 DEBUG;DEBUG 会打出海量日志,Windows 控制台同步输出会把启动拖慢到几分钟 + org.apereo.cas: INFO + +custom: + rsa: + public-key: | + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqytbHgQWZQmkwmp6WncV + aUmUNgExYDB0Sp8h1qfyKfufbX2MEM6UzSPQHIwurFqkzcHljdrGDCXWZ2Xv8c8C + 27+MOPLf4VyYQ7invqtvmEwS6c3km4QqQVdrh0DJjVYhgO8reE8LhlDFc9Vh9Fwy + Sb8pbC1EM0s2lN6ig7/9NR8+CqxW61ku+7VYnLzcRgpc0cF3K/mQwAjVf1pKjGtP + G1mpLNcHpT2yp5u950GfFtdqYgAte7k4CDlwMDKfpf+2Pg5kZAzogO5DTq/QolOW + qjZfgT2wBNpYVT+3glbhhRbc9T3/SqFeT9KlXcamBXLvKI6WuKxlAEeKTiosUgMK + 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 参数)时的默认落点(业务系统首页)。 + # 必须是绝对 URL(http(s)://...),绝不能指向 CAS 原生登录页 + # (/cas/login 或 /login)——否则登录成功会被甩回 CAS 登录页形成死循环。 + # 该值会被打包进 WAR 的 classpath application.yml,且会覆盖 LoginController 中 @Value 的默认值, + # 所以这里必须显式写成业务系统首页地址。业务系统带 service 参数时仍走各自回跳地址。 + # 注意:生产环境由 Nacos cas.yaml 的 custom.login.default-redirect-url 覆盖(优先级更高),两处需保持一致。 + default-redirect-url: https://base.yunhaixingtong.com + # 是否强制每次登录都必须校验验证码。 + # false(默认)/ 不配置:维持原逻辑,仅当同一会话登录失败达到阈值后才要求验证码。 + # true:任何登录(含首次)都必须携带并校验验证码,用于彻底阻断批量账号枚举探测。 + always-captcha: false diff --git a/src/main/resources/services/HTTPSandIMAPS-10000001.json b/src/main/resources/services/HTTPSandIMAPS-10000001.json new file mode 100644 index 0000000..cc3ac35 --- /dev/null +++ b/src/main/resources/services/HTTPSandIMAPS-10000001.json @@ -0,0 +1,7 @@ +{ + "@class": "org.apereo.cas.services.RegexRegisteredService", + "serviceId": "^(https|imaps|http)://.*", + "name": "HTTPS and HTTP and IMAPS", + "id": 10000001, + "description": "ÔÊÐíËùÓÐ HTTP/HTTPS ЭÒéµķþÎñ" +} diff --git a/src/main/resources/templates/casGenericSuccessView.html b/src/main/resources/templates/casGenericSuccessView.html new file mode 100644 index 0000000..e84fef8 --- /dev/null +++ b/src/main/resources/templates/casGenericSuccessView.html @@ -0,0 +1,44 @@ + + + + + + 请稍候 + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/casLoginView.html b/src/main/resources/templates/casLoginView.html new file mode 100644 index 0000000..d1706f3 --- /dev/null +++ b/src/main/resources/templates/casLoginView.html @@ -0,0 +1,401 @@ + + + + + + + + + + 登录 + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file