迁移 CasSsoService 源码并按 CAS war overlay 形态适配 CI
build / build-push (push) Successful in 4m8s

- 复制 CAS 5.3.16 overlay 源码(src/、pom.xml、etc/cas/config/log4j2.xml、docker/cas.properties)及本地构建辅助(build.cmd/build.sh/maven-settings.xml)
- build.yml:maven 产物由 jar 改为 war(cp cas.war app.jar),CI 内源码构建,摆脱 174MB war blob 入库
- Dockerfile:CAS 专用镜像(自签名 keystore 8443 HTTPS + 内置 cas.properties/log4j2.xml + JAVA_OPTS 注入)
- pom.xml:移除 sonatype/shibboleth 外网仓库,依赖全部经 nexus maven-public 解析
- 前端 front/ 不入库(CasSsoFront 仓库独立发布)
This commit is contained in:
2026-08-31 18:55:29 +08:00
parent 7d8f033f11
commit d6583f49f9
24 changed files with 2623 additions and 55 deletions
+9
View File
@@ -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
+3 -2
View File
@@ -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' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /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
+12
View File
@@ -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/
+8 -45
View File
@@ -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 优先,不会被覆盖。
+105
View File
@@ -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
+189
View File
@@ -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
+17 -8
View File
@@ -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"]
+14
View File
@@ -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
+117
View File
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!-- Specify the refresh internal in seconds. -->
<Configuration monitorInterval="5" packages="org.apereo.cas.logging">
<Properties>
<!--
Default log directory is the current directory but that can be overridden with -Dcas.log.dir=<logdir>
Or you can change this property to a new default
-->
<Property name="cas.log.dir" >.</Property>
<!-- To see more CAS specific logging, adjust this property to info or debug or run server with -Dcas.log.leve=debug -->
<Property name="cas.log.level" >warn</Property>
</Properties>
<Appenders>
<Console name="console" target="SYSTEM_OUT">
<PatternLayout pattern="%d %p [%c] - &lt;%m&gt;%n"/>
</Console>
<RollingFile name="file" fileName="${sys:cas.log.dir}/cas.log" append="true"
filePattern="${sys:cas.log.dir}/cas-%d{yyyy-MM-dd-HH}-%i.log">
<PatternLayout pattern="%d %p [%c] - &lt;%m&gt;%n"/>
<Policies>
<OnStartupTriggeringPolicy />
<SizeBasedTriggeringPolicy size="10 MB"/>
<TimeBasedTriggeringPolicy />
</Policies>
</RollingFile>
<RollingFile name="auditlogfile" fileName="${sys:cas.log.dir}/cas_audit.log" append="true"
filePattern="${sys:cas.log.dir}/cas_audit-%d{yyyy-MM-dd-HH}-%i.log">
<PatternLayout pattern="%d %p [%c] - %m%n"/>
<Policies>
<OnStartupTriggeringPolicy />
<SizeBasedTriggeringPolicy size="10 MB"/>
<TimeBasedTriggeringPolicy />
</Policies>
</RollingFile>
<RollingFile name="perfFileAppender" fileName="${sys:cas.log.dir}/perfStats.log" append="true"
filePattern="${sys:cas.log.dir}/perfStats-%d{yyyy-MM-dd-HH}-%i.log">
<PatternLayout pattern="%m%n"/>
<Policies>
<OnStartupTriggeringPolicy />
<SizeBasedTriggeringPolicy size="10 MB"/>
<TimeBasedTriggeringPolicy />
</Policies>
</RollingFile>
<CasAppender name="casAudit">
<AppenderRef ref="auditlogfile" />
</CasAppender>
<CasAppender name="casFile">
<AppenderRef ref="file" />
</CasAppender>
<CasAppender name="casConsole">
<AppenderRef ref="console" />
</CasAppender>
<CasAppender name="casPerf">
<AppenderRef ref="perfFileAppender" />
</CasAppender>
</Appenders>
<Loggers>
<!-- If adding a Logger with level set higher than warn, make category as selective as possible -->
<!-- Loggers inherit appenders from Root Logger unless additivity is false -->
<AsyncLogger name="org.apereo" level="${sys:cas.log.level}" includeLocation="true"/>
<AsyncLogger name="org.apereo.services.persondir" level="${sys:cas.log.level}" includeLocation="true"/>
<AsyncLogger name="org.apereo.cas.web.flow" level="info" includeLocation="true"/>
<AsyncLogger name="org.apache" level="warn" />
<AsyncLogger name="org.apache.http" level="error" />
<AsyncLogger name="org.springframework" level="warn" />
<AsyncLogger name="org.springframework.cloud.server" level="warn" />
<AsyncLogger name="org.springframework.cloud.client" level="warn" />
<AsyncLogger name="org.springframework.cloud.bus" level="warn" />
<AsyncLogger name="org.springframework.aop" level="warn" />
<AsyncLogger name="org.springframework.boot" level="warn" />
<AsyncLogger name="org.springframework.boot.actuate.autoconfigure" level="warn" />
<AsyncLogger name="org.springframework.webflow" level="warn" />
<AsyncLogger name="org.springframework.session" level="warn" />
<AsyncLogger name="org.springframework.amqp" level="error" />
<AsyncLogger name="org.springframework.integration" level="warn" />
<AsyncLogger name="org.springframework.messaging" level="warn" />
<AsyncLogger name="org.springframework.web" level="warn" />
<AsyncLogger name="org.springframework.orm.jpa" level="warn" />
<AsyncLogger name="org.springframework.scheduling" level="warn" />
<AsyncLogger name="org.springframework.context.annotation" level="error" />
<AsyncLogger name="org.springframework.boot.devtools" level="error" />
<AsyncLogger name="org.springframework.web.socket" level="warn" />
<AsyncLogger name="org.thymeleaf" level="warn" />
<AsyncLogger name="org.pac4j" level="warn" />
<AsyncLogger name="org.opensaml" level="warn"/>
<AsyncLogger name="net.sf.ehcache" level="warn" />
<AsyncLogger name="com.couchbase" level="warn" includeLocation="true"/>
<AsyncLogger name="com.ryantenney.metrics" level="warn" />
<AsyncLogger name="net.jradius" level="warn" />
<AsyncLogger name="org.openid4java" level="warn" />
<AsyncLogger name="org.ldaptive" level="warn" />
<AsyncLogger name="com.hazelcast" level="warn" />
<AsyncLogger name="org.apereo.spring" level="warn" />
<!-- Log perf stats only to perfStats.log -->
<AsyncLogger name="perfStatsLogger" level="info" additivity="false" includeLocation="true">
<AppenderRef ref="casPerf"/>
</AsyncLogger>
<!-- Log audit to all root appenders, and also to audit log (additivity is not false) -->
<AsyncLogger name="org.apereo.inspektr.audit.support" level="info" includeLocation="true" >
<AppenderRef ref="casAudit"/>
</AsyncLogger>
<!-- All Loggers inherit appenders specified here, unless additivity="false" on the Logger -->
<AsyncRoot level="warn">
<AppenderRef ref="casFile"/>
<!--
For deployment to an application server running as service,
delete the casConsole appender below
-->
<AppenderRef ref="casConsole"/>
</AsyncRoot>
</Loggers>
</Configuration>
+37
View File
@@ -0,0 +1,37 @@
<settings>
<!-- Maven 3.8+ 默认拦截 HTTP 仓库(maven-default-http-blocker)。
nexus maven-public 仅提供 HTTP,故在此覆盖该 blocker,放行内网 nexus。 -->
<mirrors>
<mirror>
<id>maven-default-http-blocker</id>
<mirrorOf>external:http:*</mirrorOf>
<name>nexus maven-public (allow http)</name>
<url>http://192.168.10.160:8081/repository/maven-public/</url>
<blocked>false</blocked>
</mirror>
</mirrors>
<profiles>
<profile>
<id>nexus</id>
<repositories>
<repository>
<id>central</id>
<url>http://192.168.10.160:8081/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>central</id>
<url>http://192.168.10.160:8081/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>nexus</activeProfile>
</activeProfiles>
</settings>
+351
View File
@@ -0,0 +1,351 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd ">
<modelVersion>4.0.0</modelVersion>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-overlay</artifactId>
<packaging>war</packaging>
<version>1.0</version>
<properties>
<cas.version>5.3.16</cas.version>
<springboot.version>1.5.18.RELEASE</springboot.version>
<app.server>-tomcat</app.server>
<mainClassName>org.springframework.boot.loader.WarLauncher</mainClassName>
<isExecutable>false</isExecutable>
<manifestFileToUse>${project.build.directory}/war/work/org.apereo.cas/cas-server-webapp${app.server}/META-INF/MANIFEST.MF</manifestFileToUse>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- 覆盖 spring-boot-dependencies 默认的 thymeleaf 2.1.6,与 CAS 5.3.16 自带的 3.0.11 对齐,避免双版本冲突 -->
<thymeleaf.version>3.0.11.RELEASE</thymeleaf.version>
<thymeleaf-spring4.version>3.0.11.RELEASE</thymeleaf-spring4.version>
<!-- 覆盖 spring-boot-dependencies 默认版本,与 CAS 5.3.16 webapp 自带版本对齐,消除双版本冲突 -->
<spring.version>4.3.25.RELEASE</spring.version>
<jackson.version>2.9.9</jackson.version>
<tomcat.version>8.5.47</tomcat.version>
<log4j2.version>2.12.1</log4j2.version>
<hikari.version>3.2.0</hikari.version>
<spring-security.version>4.2.10.RELEASE</spring-security.version>
<caffeine.version>2.6.2</caffeine.version>
<commons-codec.version>1.11</commons-codec.version>
<metrics.version>3.2.5</metrics.version>
</properties>
<!-- ===== 新增 Dependency Management ===== -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${springboot.version}</version>
<type>pom</type>
<scope>import</scope>
<exclusions>
<exclusion>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</exclusion>
<exclusion>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>com.rimerosolutions.maven.plugins</groupId>
<artifactId>wrapper-maven-plugin</artifactId>
<version>0.0.5</version>
<configuration>
<verifyDownload>true</verifyDownload>
<checksumAlgorithm>MD5</checksumAlgorithm>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${springboot.version}</version>
<configuration>
<mainClass>${mainClassName}</mainClass>
<addResources>true</addResources>
<executable>${isExecutable}</executable>
<layout>WAR</layout>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>2.6</version>
<configuration>
<warName>cas</warName>
<failOnMissingWebXml>false</failOnMissingWebXml>
<recompressZippedFiles>false</recompressZippedFiles>
<archive>
<compress>false</compress>
<manifestFile>${manifestFileToUse}</manifestFile>
</archive>
<overlays>
<overlay>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-webapp${app.server}</artifactId>
</overlay>
</overlays>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.3</version>
</plugin>
</plugins>
<finalName>cas</finalName>
</build>
<!-- 不声明外网仓库(sonatype/shibboleth 已移除):CAS 5.3.16 全部依赖都在 Maven Central,
CI(内网 runner 无外网)与本地(maven-settings.xml 指公司 nexus maven-public)均经 nexus 代理解析 -->
<profiles>
<profile>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<id>default</id>
<dependencies>
<!-- CAS Web 应用 -->
<dependency>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-webapp${app.server}</artifactId>
<version>${cas.version}</version>
<type>war</type>
<scope>runtime</scope>
<exclusions>
<exclusion>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</exclusion>
<exclusion>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- CAS 核心 API -->
<dependency>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-core-api-authentication</artifactId>
<version>${cas.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-core-authentication-api</artifactId>
<version>${cas.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-core-configuration-api</artifactId>
<version>${cas.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-core-api-ticket</artifactId>
<version>${cas.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-core-configuration</artifactId>
<version>${cas.version}</version>
<scope>provided</scope>
</dependency>
<!-- SSO 会话所需:CentralAuthenticationService 等核心 API -->
<dependency>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-core-api</artifactId>
<version>${cas.version}</version>
<scope>provided</scope>
</dependency>
<!-- SSO 会话所需:CookieRetrievingCookieGenerator / TGCCookieRetrievingCookieGenerator -->
<dependency>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-core-cookie-api</artifactId>
<version>${cas.version}</version>
<scope>provided</scope>
</dependency>
<!-- SSO 会话所需:WebApplicationServiceFactory(构造 Service 以签发 ST) -->
<dependency>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-core-services-authentication</artifactId>
<version>${cas.version}</version>
<scope>provided</scope>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope>
<exclusions>
<exclusion>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Log4j2 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
<version>${springboot.version}</version>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<scope>provided</scope>
<exclusions>
<exclusion>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 验证码 -->
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
<!-- RSA -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.68</version>
</dependency>
<!-- Fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.83</version>
</dependency>
<!-- ===== JDBC + MySQL ===== -->
<!-- JDBC 支持(排除 HikariCP) -->
<dependency>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-support-jdbc</artifactId>
<version>${cas.version}</version>
<exclusions>
<exclusion>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-support-jdbc-drivers</artifactId>
<version>${cas.version}</version>
<exclusions>
<exclusion>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 正确版本的 HikariCP(与 webapp 自带 3.2.0 对齐) -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>3.2.0</version>
<scope>provided</scope>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.46</version>
</dependency>
<!-- Spring Security Config(提供 WebSecurityConfigurerAdapter、HttpSecurity 等;webapp 不自带,必须打包进 WAR) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
<!-- Spring Security Web(提供 WebSecurity 等;webapp 不自带,必须打包进 WAR) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
</dependency>
<!-- Spring Security Crypto(BCrypt) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
<version>4.2.10.RELEASE</version>
<scope>provided</scope>
</dependency>
<!-- SnakeYAML:解析 Nacos 下发的 YAML 配置(webapp 已自带 1.17,provided 仅用于编译期,运行期复用 webapp 版本避免冲突) -->
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>1.17</version>
<scope>provided</scope>
</dependency>
</dependencies>
</profile>
</profiles>
</project>
@@ -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());
}
}
@@ -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);
}
}
@@ -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<String, String> 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<String, String> 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+", "");
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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() {
// 工具类禁止实例化
}
}
@@ -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 "<编译输出目录>;<cas依赖>" 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));
}
}
}
@@ -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/**
+113
View File
@@ -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
@@ -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 协议的服务"
}
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>请稍候</title>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
</head>
<body>
<!--
覆盖 CAS 原生 casGenericSuccessView.html:已有有效 TGT 后再访问 /cas/login(无 service)
时,CAS 不再渲染默认"您已经成功登录中央认证系统"页,而是自动跳转到配置的默认落点
(custom.login.default-redirect-url,即 base.yunhaixingtong.com)。
跳转地址复用 /cas/api/check-sso 的返回(跟随 Nacos 配置,不硬编码);接口异常时兜底。
-->
<script>
(function () {
var fallback = "https://base.yunhaixingtong.com";
try {
var xhr = new XMLHttpRequest();
xhr.open("GET", "/cas/api/check-sso", true);
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
try {
var resp = JSON.parse(xhr.responseText);
if (resp && resp.code == 0 && resp.data && resp.data.url) {
window.location.replace(resp.data.url);
return;
}
} catch (e) { /* 兜底 */ }
window.location.replace(fallback);
};
xhr.send();
} catch (e) {
window.location.replace(fallback);
}
})();
</script>
</body>
</html>
@@ -0,0 +1,401 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>登录</title>
<!-- 覆盖 CAS 原生 casLoginView.html:视觉采用 front/CasSsoFront(完整版企业登录页),
逻辑由 native-login.js 接管(含 check-sso 免密预检 + RSA 登录)。
URL 是 /cas/login,所有资源必须绝对路径(相对路径会解析到 /cas/libs/)。 -->
<script src="/cas/front/libs/jquery.min.js?v=2.1"></script>
<script src="/cas/front/js/jsencrypt.min.js?v=2.1"></script>
<script src="/cas/front/js/conf.js?v=2.3"></script>
<script src="/cas/front/js/bschlogin.js?v=2.1"></script>
<script src="/cas/front/js/native-login.js?v=3.0"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #eef2f7;
color: #1f2d4d;
}
.login-wrap {
display: flex;
min-height: 100vh;
}
/* 左侧品牌区 */
.login-banner {
flex: 0 0 46%;
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 7%;
color: #fff;
overflow: hidden;
background: linear-gradient(155deg, #1e3a8a 0%, #2563eb 52%, #3b82f6 100%);
}
.login-banner::before {
content: "";
position: absolute;
top: -140px;
left: -100px;
width: 360px;
height: 360px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.16), transparent 70%);
}
.login-banner::after {
content: "";
position: absolute;
right: -120px;
bottom: -120px;
width: 380px;
height: 380px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.12), transparent 70%);
}
.banner-inner {
position: relative;
z-index: 1;
max-width: 420px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 52px;
}
.brand-logo {
height: 44px;
width: auto;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.25));
}
.brand-name {
font-size: 22px;
font-weight: 700;
letter-spacing: 1px;
}
.banner-title {
font-size: 34px;
font-weight: 700;
line-height: 1.35;
margin-bottom: 16px;
}
.banner-desc {
font-size: 16px;
line-height: 1.7;
opacity: 0.9;
margin-bottom: 38px;
}
.banner-points {
list-style: none;
}
.banner-points li {
position: relative;
padding-left: 22px;
font-size: 15px;
line-height: 2.3;
opacity: 0.92;
}
.banner-points li::before {
content: "";
position: absolute;
left: 0;
top: 16px;
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.85);
}
.banner-footer {
position: absolute;
left: 7%;
right: 7%;
bottom: 26px;
z-index: 1;
font-size: 12px;
opacity: 0.7;
}
/* 右侧登录区 */
.login-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
}
.login-card {
width: 380px;
max-width: 100%;
background: #fff;
border-radius: 14px;
box-shadow: 0 14px 44px rgba(31, 45, 80, 0.12);
padding: 42px 36px 34px;
}
.login-title {
font-size: 22px;
font-weight: 700;
text-align: center;
margin-bottom: 30px;
}
.form-item {
margin-bottom: 18px;
}
.form-item input {
width: 100%;
padding: 13px 14px;
border: 1px solid #dfe3ea;
border-radius: 8px;
font-size: 14px;
color: #1f2d4d;
background: #fbfcfe;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.form-item input:focus {
border-color: #2563eb;
background: #fff;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.captcha-row {
display: flex;
gap: 10px;
align-items: center;
}
.captcha-row input {
flex: 1;
}
.captcha-row img {
height: 46px;
cursor: pointer;
border-radius: 8px;
border: 1px solid #dfe3ea;
}
.btn-submit {
width: 100%;
padding: 13px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
color: #fff;
cursor: pointer;
background: linear-gradient(135deg, #2563eb, #3b82f6);
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.28);
transition: transform 0.1s, box-shadow 0.2s, opacity 0.2s;
}
.btn-submit:hover {
box-shadow: 0 8px 22px rgba(37, 99, 235, 0.38);
}
.btn-submit:active {
transform: translateY(1px);
}
.btn-submit:disabled {
opacity: 0.7;
cursor: not-allowed;
box-shadow: none;
}
.warning {
color: #f56c6c;
font-size: 13px;
margin-top: 14px;
text-align: center;
min-height: 20px;
}
/* 登录成功遮罩 */
.login-success {
position: fixed;
inset: 0;
z-index: 99;
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
background: rgba(255, 255, 255, 0.97);
}
.login-success .check-circle {
width: 84px;
height: 84px;
border-radius: 50%;
background: #e8f5ee;
display: flex;
align-items: center;
justify-content: center;
animation: lc-pop 0.35s ease;
}
.login-success .check-circle svg {
width: 44px;
height: 44px;
}
.check-path {
stroke: #22c55e;
stroke-width: 5;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 50;
stroke-dashoffset: 50;
animation: lc-draw 0.4s 0.15s forwards ease;
}
.login-success .success-text {
margin-top: 18px;
font-size: 16px;
font-weight: 600;
color: #22c55e;
}
@keyframes lc-pop {
0% {
transform: scale(0.6);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes lc-draw {
to {
stroke-dashoffset: 0;
}
}
/* 移动端:隐藏品牌区,卡片居中 */
@media (max-width: 860px) {
.login-banner {
display: none;
}
.login-wrap {
background: linear-gradient(155deg, #1e3a8a, #3b82f6);
}
.login-card {
box-shadow: 0 14px 44px rgba(0, 0, 0, 0.18);
}
}
</style>
</head>
<body>
<div class="login-wrap">
<aside class="login-banner">
<div class="banner-inner">
<div class="brand">
<img class="brand-logo" src="/cas/front/image/logo.png" alt="logo" />
<span class="brand-name">云海星通</span>
</div>
<h1 class="banner-title">统一身份认证平台</h1>
<p class="banner-desc">一次登录,畅享所有校园应用<br />安全、便捷的单点登录服务</p>
<ul class="banner-points">
<li>统一账号体系,免去多套密码</li>
<li>单点登录,应用间无缝切换</li>
<li>全程加密传输,保障数据安全</li>
</ul>
</div>
<div class="banner-footer">
©2021 bestsch.com inc All Rights reserved
</div>
</aside>
<main class="login-main">
<div class="login-card">
<h2 class="login-title">账号登录</h2>
<form id="loginForm" onsubmit="return false;">
<div class="form-item">
<input type="text" id="user_input" name="username"
placeholder="用户名/手机号/身份证号" autocomplete="username" />
</div>
<div class="form-item">
<input type="password" id="password_input" name="password"
placeholder="请输入密码" autocomplete="current-password" />
</div>
<input type="hidden" id="loginname_input" name="loginname" />
<input type="hidden" id="pwd_input" name="pwd" />
<input type="hidden" id="captcha_hidden" name="captcha" />
<input type="hidden" id="service_hidden" name="service" />
<div class="form-item" id="captchaPanel" style="display:none">
<div class="captcha-row">
<input type="text" id="captcha_input" placeholder="输入验证码" autocomplete="off" />
<img id="captchaImg" title="点击刷新" />
</div>
</div>
<button class="btn-submit" type="button" id="loginBtn">登录</button>
<div class="warning" id="warningLabel"></div>
</form>
</div>
</main>
</div>
<!-- 登录成功遮罩:由 native-login.js 在登录成功后显示 -->
<div class="login-success" id="loginSuccess">
<div class="check-circle">
<svg viewBox="0 0 54 54" xmlns="http://www.w3.org/2000/svg">
<path class="check-path" d="M15 28 L23 36 L40 18" />
</svg>
</div>
<div class="success-text">登录成功</div>
</div>
<!-- 记住密码最佳努力:真实表单提交到隐藏 iframe(不跳转页面);主流程用 AJAX 拿 data.url 手动跳转,互不干扰 -->
<iframe name="pwdMgrFrame" style="display:none"></iframe>
<form id="pwdMgrForm" method="post" action="/cas/api/login" target="pwdMgrFrame" style="display:none">
<input type="text" name="username" id="pm_user" />
<input type="password" name="password" id="pm_pwd" />
<input type="hidden" name="loginname" id="pm_loginname" />
<input type="hidden" name="pwd" id="pm_pwd_enc" />
<input type="hidden" name="captcha" id="pm_captcha" />
<input type="hidden" name="service" id="pm_service" />
</form>
</body>
</html>