- 复制 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:
@@ -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
|
||||||
@@ -50,7 +50,8 @@ jobs:
|
|||||||
# 通过 docker.sock 调宿主 dockerd,故 -v 路径按宿主解析;用 docker cp 进出源码/产物
|
# 通过 docker.sock 调宿主 dockerd,故 -v 路径按宿主解析;用 docker cp 进出源码/产物
|
||||||
# m2 缓存挂宿主固定目录 /opt/docker/projman-m2,跨构建复用依赖
|
# m2 缓存挂宿主固定目录 /opt/docker/projman-m2,跨构建复用依赖
|
||||||
# settings.xml 用 profile 方式追加 nexus maven-public 仓库(匿名可拉,不带凭据)
|
# 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 绑定
|
# 故 mvn package 后在容器内 cp 成固定名 app.jar,使 docker cp 与 docker/Dockerfile 的 COPY target/app.jar 绑定
|
||||||
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
||||||
run: |
|
run: |
|
||||||
@@ -63,7 +64,7 @@ jobs:
|
|||||||
docker cp "${PWD}/." mavenbuild:/build/
|
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 "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 && 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"
|
mkdir -p "${PWD}/target"
|
||||||
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
|
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
|
||||||
docker rm -f mavenbuild
|
docker rm -f mavenbuild
|
||||||
|
|||||||
+12
@@ -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/
|
||||||
@@ -1,53 +1,16 @@
|
|||||||
# CasService
|
# CasService
|
||||||
|
|
||||||
> 本仓库由 projman 自动创建并注入 CI(`.gitea/workflows/build.yml`)与 Dockerfile(`docker/Dockerfile`)。
|
CAS 5.3.16 单点登录服务(war overlay 项目,源自 CasSsoService 改版迁移)。本仓库由 projman 创建并注入 CI 与 Dockerfile,已按 CAS 项目形态适配。
|
||||||
> 推送代码到 main 分支即自动构建镜像并推送到 Nexus,projman 收到通知后建档待部署,由运维在 projman「后端发布」手动点部署。
|
|
||||||
|
|
||||||
## 首次推送本地代码到本仓库
|
## 与标准 springboot CI 模板的差异
|
||||||
|
|
||||||
> 目标:把本地已有代码推上去,同时**保留** projman 注入的文件(`build.yml`、`Dockerfile`)。
|
- **构建产物是 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`。
|
||||||
```bash
|
- **健康检查**:`/cas/status`(HTTPS 自签名),projman 应用需配置 healthPath=`/cas/status` + HTTPS 开。
|
||||||
# 1. 进入本地代码目录
|
- **pom 不声明外网仓库**:依赖全部经 nexus maven-public 代理解析(内网 CI 无外网)。
|
||||||
cd /your/local/code/dir
|
- 前端登录页静态文件由 CasSsoFront 仓库独立发布(`src/main/webapp/front/` 不入库)。
|
||||||
|
|
||||||
# 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## 推送后验证
|
## 推送后验证
|
||||||
|
|
||||||
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 优先,不会被覆盖。
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -1,18 +1,27 @@
|
|||||||
# 由 projman 注入的 Spring Boot 运行镜像(单阶段,jar 预构建)
|
# CAS 5.3.16 运行镜像(单阶段,war 预构建)
|
||||||
# CI 已先 mvn package,并在容器内把产物 cp 成固定名 target/app.jar
|
# CI 已先 mvn package 并在容器内把产物 cp 成固定名 target/app.jar(= cas.war,可执行 WAR)
|
||||||
# 构建上下文 = 仓库根:docker build -f docker/Dockerfile .
|
# 构建上下文 = 仓库根: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)
|
# 时区 Asia/Shanghai;JAVA_OPTS 由 projman 部署 env 注入(默认 -Xms512m -Xmx1024m)
|
||||||
FROM eclipse-temurin:8-jre
|
FROM eclipse-temurin:8-jre
|
||||||
|
|
||||||
RUN apt-get update \
|
# CAS 5.3 默认要求 HTTPS(安全 Cookie),生成自签名密钥库(密码固定 changeit,过渡期用)
|
||||||
&& apt-get install -y --no-install-recommends curl tzdata \
|
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 \
|
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
&& echo "Asia/Shanghai" > /etc/timezone \
|
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
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
|
# 镜像内 cas.properties 仅保留环境无关配置(HTTPS/端口/日志/管理页白名单);
|
||||||
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar"]
|
# 业务配置(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"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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] - <%m>%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] - <%m>%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>
|
||||||
@@ -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>
|
||||||
@@ -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/**
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user