init: CasSsoService CAS 5.3 overlay (cas.war via LFS)

This commit is contained in:
chenxing
2026-08-05 09:53:49 +08:00
commit 3bd14554ec
93 changed files with 19827 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
docker/cas.war filter=lfs diff=lfs merge=lfs -text
+42
View File
@@ -0,0 +1,42 @@
name: deploy-cassso-service
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-deploy:
runs-on: ubuntu-latest
# docker:24 自带 docker CLI;宿主 docker.sock 默认挂入 job 容器。
container:
image: docker:24
steps:
- name: Install git + git-lfs (alpine)
run: apk add --no-cache git git-lfs
- name: Checkout (manual, internal Gitea) + LFS pull
# cas.war (174MB) 走 Git LFS 存储,clone 后需 git lfs pull 取回真实文件
run: |
git clone --depth 1 --branch main http://192.168.10.160:3000/${GITHUB_REPOSITORY}.git /tmp/src
cd /tmp/src && git lfs pull
cp -a /tmp/src/. .
rm -rf .git .gitea
rm -rf /tmp/src
- name: Login to Nexus docker registry
run: echo '${{ secrets.NEXUS_PASSWORD }}' | docker login -u '${{ secrets.NEXUS_USER }}' --password-stdin nexus:8083
- name: Build image (copies docker/cas.war)
run: docker build -f docker/Dockerfile -t nexus:8083/docker-hosted/cassso-service:latest .
- name: Push image
run: docker push nexus:8083/docker-hosted/cassso-service:latest
- name: Deploy on host (infra-infra-net, CAS 端口 8443)
run: |
docker rm -f cas-sso 2>/dev/null || true
docker run -d --name cas-sso --network infra-infra-net -p 8443:8443 --restart unless-stopped nexus:8083/docker-hosted/cassso-service:latest
- name: Done
run: 'echo "CasSsoService deployed -> https://192.168.10.160:8443/login"'
+4
View File
@@ -0,0 +1,4 @@
docker/live_cas.war
docker/diag*.py
docker/verify*.py
*.iml
+101
View File
@@ -0,0 +1,101 @@
CAS Overlay Template
============================
Generic CAS WAR overlay to exercise the latest versions of CAS. This overlay could be freely used as a starting template for local CAS war overlays. The CAS services management overlay is available [here](https://github.com/apereo/cas-services-management-overlay).
# Versions
```xml
<cas.version>5.3.x</cas.version>
```
# Requirements
* JDK 1.8+
# Configuration
The `etc` directory contains the configuration files and directories that need to be copied to `/etc/cas/config`.
# Build
To see what commands are available to the build script, run:
```bash
./build.sh help
```
To package the final web application, run:
```bash
./build.sh package
```
To update `SNAPSHOT` versions run:
```bash
./build.sh package -U
```
# Deployment
- Create a keystore file `thekeystore` under `/etc/cas`. Use the password `changeit` for both the keystore and the key/certificate entries.
- Ensure the keystore is loaded up with keys and certificates of the server.
On a successful deployment via the following methods, CAS will be available at:
* `http://cas.server.name:8080/cas`
* `https://cas.server.name:8443/cas`
## Executable WAR
Run the CAS web application as an executable WAR.
```bash
./build.sh run
```
## Spring Boot
Run the CAS web application as an executable WAR via Spring Boot. This is most useful during development and testing.
```bash
./build.sh bootrun
```
### Warning!
Be careful with this method of deployment. `bootRun` is not designed to work with already executable WAR artifacts such that CAS server web application. YMMV. Today, uses of this mode ONLY work when there is **NO OTHER** dependency added to the build script and the `cas-server-webapp` is the only present module. See [this issue](https://github.com/spring-projects/spring-boot/issues/8320) for more info.
## Spring Boot App Server Selection
There is an app.server property in the `pom.xml` that can be used to select a spring boot application server.
It defaults to `-tomcat` but `-jetty` and `-undertow` are supported.
It can also be set to an empty value (nothing) if you want to deploy CAS to an external application server of your choice.
```xml
<app.server>-tomcat<app.server>
```
## Windows Build
If you are building on windows, try `build.cmd` instead of `build.sh`. Arguments are similar but for usage, run:
```
build.cmd help
```
## External
Deploy resultant `target/cas.war` to a servlet container of choice.
## Command Line Shell
Invokes the CAS Command Line Shell. For a list of commands either use no arguments or use `-h`. To enter the interactive shell use `-sh`.
```bash
./build.sh cli
```
+19
View File
@@ -0,0 +1,19 @@
# 本地推送 / 远端部署 用的环境变量示例
# 用法:复制为 .env 或直接 export 后执行脚本
# ---- ACR 阿里云镜像仓库 ----
ACR_NAMESPACE=your-namespace # 必填:阿里云镜像命名空间
ACR_USERNAME=your-acr-username # 必填:阿里云仓库登录用户名
ACR_PASSWORD=your-acr-password # 必填:阿里云仓库独立登录密码
ACR_REGISTRY_PUBLIC=registry.cn-hangzhou.aliyuncs.com
# ---- 部署到远端 ----
DEPLOY_HOST=192.168.10.157 # ECS 可达地址
DEPLOY_PORT=22
DEPLOY_USER=yunhai
REMOTE_DIR=/opt/docker/apps/cas-sso
SSHPASS=123456 # 远端登录密码(remote_run/upload 用)
# ---- CAS 自带 MySQL 密码 ----
MYSQL_PASSWORD=Cas@123456
MYSQL_ROOT_PASSWORD=Root@123456
+23
View File
@@ -0,0 +1,23 @@
# CAS 5.3.16 镜像(基于已构建好的 target/cas.war)
# 构建上下文 = 项目根目录: docker build -f docker/Dockerfile -t <img> .
FROM eclipse-temurin:8-jre
# CAS 5.3 默认要求 HTTPS(安全 Cookie),这里生成自签名密钥库(密码固定 changeit,过渡期用)
RUN mkdir -p /etc/cas/config \
&& keytool -genkeypair -alias cas -keyalg RSA -keysize 2048 -validity 3650 \
-storetype JKS -keystore /etc/cas/cas.keystore \
-storepass changeit -keypass changeit -dname "CN=cas.example.org" 2>/dev/null || true \
&& apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY docker/cas.war /app/cas.war
COPY docker/cas.properties /etc/cas/config/cas.properties
COPY docker/run-cas.sh /app/run-cas.sh
COPY etc/cas/config/log4j2.xml /etc/cas/config/log4j2.xml
RUN chmod +x /app/run-cas.sh
# 镜像内 cas.properties 仅作「nacos 不可达时的兜底默认」;
# 正常启动由 run-cas.sh 从 nacos 拉取线上配置(dataId=cas-prod.properties)后覆盖。
EXPOSE 8443
ENTRYPOINT ["/app/run-cas.sh"]
+31
View File
@@ -0,0 +1,31 @@
# ============================================================
# 公共基础配置 base-common
# 部署位置:Nacos namespace=public group=DEFAULT_GROUP dataId=base-common.properties
# 用途:所有服务共享的 mysql / redis / rabbitmq 连接信息(只放连接,不放业务库名)
# CAS 的 run-cas.sh 会读取 spring.datasource.* 拼出 JDBC URL,
# 库名在 cas-prod.properties 的 cas.authn.jdbc.query[0].database 中指定(如 bsch_userbase)
# ============================================================
# ---------- MySQL(公共 mysql8,服务名 mysql8,位于 infra-infra-net)----------
spring.datasource.host=mysql8
spring.datasource.port=3306
spring.datasource.username=__SHARED_MYSQL_USER__
spring.datasource.password=__SHARED_MYSQL_PASSWORD__
spring.datasource.params=useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
# ---------- Redis ----------
spring.redis.host=__REDIS_HOST__
spring.redis.port=6379
spring.redis.password=__SHARED_REDIS_PASSWORD__
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
# ---------- RabbitMQ ----------
spring.rabbitmq.host=__RABBITMQ_HOST__
spring.rabbitmq.port=5672
spring.rabbitmq.username=__SHARED_RABBIT_USER__
spring.rabbitmq.password=__SHARED_RABBIT_PASSWORD__
spring.rabbitmq.virtual-host=/
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# 本地构建 CAS 镜像并推送到阿里云「公网」 registry
# 用法: ./build-push.sh <version>
# 前置环境变量: ACR_NAMESPACE, ACR_USERNAME, ACR_PASSWORD
set -euo pipefail
VERSION="${1:-latest}"
REGISTRY="${ACR_REGISTRY_PUBLIC:-registry.cn-hangzhou.aliyuncs.com}"
NAMESPACE="${ACR_NAMESPACE:?请设置 ACR_NAMESPACE(阿里云镜像命名空间)}"
IMG="$REGISTRY/$NAMESPACE/yunhai:$VERSION"
cd "$(dirname "$0")/.." # 切到项目根(构建上下文需含 target/cas.war)
echo "== build $IMG =="
docker build -f docker/Dockerfile -t "$IMG" .
echo "== login $REGISTRY =="
echo "$ACR_PASSWORD" | docker login --username="$ACR_USERNAME" --password-stdin "$REGISTRY"
echo "== push =="
docker push "$IMG"
echo "PUSHED $IMG"
+29
View File
@@ -0,0 +1,29 @@
# CAS 自身配置(部署到 Nacos:namespace=cas, group=CAS, dataId=cas-prod.properties)
# 说明:
# - JDBC 的连接信息(URL / 账号 / 密码)由公共 base-common 注入,这里【不要】写 url/user/password
# - 这里只写「库名」(database) 与查询/密码编码相关项
# - 其余 CAS 专属配置(tgc / webflow / server / custom.rsa 等)保持你现有的不变
cas.server.name: https://localhost:8443
cas.server.prefix: https://localhost:8443/cas
cas.serviceRegistry.initFromJson: true
cas.logout.followServiceRedirects: true
# ===== JDBC 认证(库名写这里,连接来自 base-common)=====
cas.authn.jdbc.query[0].database=bsch_userbase
cas.authn.jdbc.query[0].sql=select * from sys_user where username=?
cas.authn.jdbc.query[0].fieldPassword=password
cas.authn.jdbc.query[0].fieldDisabled=disabled
cas.authn.jdbc.query[0].fieldExpired=expired
cas.authn.jdbc.query[0].passwordEncoder.type=BCRYPT
cas.authn.jdbc.query[0].passwordEncoder.strength=10
# 自定义 /api/login 登录成功后的默认跳转地址(URL 不带 service 参数时生效)。
# - 支持绝对地址,如 http://sso.yunhaixingtong.com(绝对地址会直接作为跳转目标,不再拼 CAS 自身 host)
# - 也支持相对路径,如 / 或 /cas/login
# - 不配此键时,代码内置默认值 = http://sso.yunhaixingtong.com
# 带 service 参数时,仍按原逻辑跳转该 service(控制器返回带 ticket 的 service 地址)。
custom.login.default-redirect-url=http://sso.yunhaixingtong.com
# ... 其余 CAS 配置(tgc / webflow / custom.rsa / custom.login 等)同你现有内容 ...
+28
View File
@@ -0,0 +1,28 @@
# CAS 烤进镜像的兜底配置(仅当 Nacos 不可用时由 run-cas.sh 回退使用)
# 正常情况下以 Nacos 的 cas-prod.properties(连接信息来自 base-common)为准
cas.server.name: https://localhost:8443
cas.server.prefix: https://localhost:8443/cas
cas.adminPagesSecurity.ip=127\.0\.0\.1
logging.config: file:/etc/cas/config/log4j2.xml
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
# ===== 数据库认证(兜底:连公共 mysql8 上的 bsch_userbase)=====
# 注意:以下 user/password 为占位符;Nacos 不可用时需改为公共 mysql8 真实账号
cas.authn.jdbc.query[0].sql: select * from sys_user where username=?
cas.authn.jdbc.query[0].url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
cas.authn.jdbc.query[0].user: __SHARED_MYSQL_USER__
cas.authn.jdbc.query[0].password: __SHARED_MYSQL_PASSWORD__
cas.authn.jdbc.query[0].driverClass: com.mysql.jdbc.Driver
cas.authn.jdbc.query[0].fieldPassword: password
cas.authn.jdbc.query[0].fieldDisabled: disabled
cas.authn.jdbc.query[0].fieldExpired: expired
cas.authn.jdbc.query[0].passwordEncoder.type: BCRYPT
cas.authn.jdbc.query[0].passwordEncoder.strength: 10
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d777752b9fa45e17dd5e96aafef6ca9ae16f5fb30a9d042424c2a33b739747b6
size 173749577
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# 一键部署:上传编排文件 -> SSH 到 ECS 拉取镜像并启动(CAS,依赖公共 mysql8/redis/rabbitmq)
# 用法: ./deploy.sh <version>
# 前置环境变量:
# DEPLOY_HOST (必填) ECS 公网/私网 IP 或域名(SSH 目标,不是 ACR 仓库地址!)
# SSHPASS (必填) ECS 上 $DEPLOY_USER 的 SSH 密码
# ACR_NAMESPACE (必填) 阿里云镜像命名空间,如 damaomi_docker
# ACR_PASSWORD (可选) 若提供则在 ECS 上自动 docker login,避免 ECS 未登录导致 pull 失败
# ACR_USERNAME (可选) 默认 15121037482
# DEPLOY_USER / DEPLOY_PORT / REMOTE_DIR 均有默认值
set -euo pipefail
VERSION="${1:-latest}"
HOST="${DEPLOY_HOST:?请设置 DEPLOY_HOST(ECS 的 IP/域名,不是 ACR 仓库地址)}"
PORT="${DEPLOY_PORT:-22}"
USER="${DEPLOY_USER:-yunhai}"
REMOTE_DIR="${REMOTE_DIR:-/opt/docker/apps/cas-sso}"
ACR_NAMESPACE="${ACR_NAMESPACE:?请设置 ACR_NAMESPACE(如 damaomi_docker)}"
ACR_USERNAME="${ACR_USERNAME:-15121037482}"
ACR_PASSWORD="${ACR_PASSWORD:-}"
# 镜像走公网端点(内网 registry-vpc 在不在 VPC 的 ECS 上连不通)
ACR_REGISTRY="${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}"
cd "$(dirname "$0")"
echo "== 1/2 上传 compose + run-cas.sh + init.sql + cas.war 到 $HOST:$REMOTE_DIR =="
python3 upload.py "$HOST" "$PORT" "$USER" \
docker-compose.cas.yml "$REMOTE_DIR/docker-compose.cas.yml" \
run-cas.sh "$REMOTE_DIR/run-cas.sh" \
init.sql "$REMOTE_DIR/init.sql"
# 若提供了 ACR 密码,生成自动登录命令(避免 ECS 未登录导致 pull 失败)
LOGIN_CMD=""
if [ -n "$ACR_PASSWORD" ]; then
LOGIN_CMD="docker login $ACR_REGISTRY -u $ACR_USERNAME -p $ACR_PASSWORD"
fi
echo "== 2/2 远端拉取镜像并启动 =="
python3 remote_run.py "$HOST" "$PORT" "$USER" <<EOF
set -e
mkdir -p "$REMOTE_DIR"
cd "$REMOTE_DIR"
cat > .env <<ENV
ACR_REGISTRY=$ACR_REGISTRY
ACR_NAMESPACE=$ACR_NAMESPACE
VERSION=$VERSION
MYSQL_PASSWORD=${MYSQL_PASSWORD:-Cas@123456}
MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-Root@123456}
ENV
/bin/chmod +x "$REMOTE_DIR/run-cas.sh"
$LOGIN_CMD
docker compose -f docker-compose.cas.yml pull cas
docker compose -f docker-compose.cas.yml up -d --force-recreate
docker image prune -f
echo DEPLOY_OK
EOF
+85
View File
@@ -0,0 +1,85 @@
COPIED_OK
local war size: 173751847
=== RUNNING WAR LoginController features ===
has window.location.href (v1.8 success): True
has sendRedirect (error path): True
has text/html (v1.8 success): True
=== RUNNING WAR index.html ===
has action=/api/login: True
has ?v=1.8: True
has name=username: True
=== container filesystem: any default-redirect-url? ===
---env---
=== effective cas.properties (head) ===
# CAS prod config (managed in nacos, dataId=cas-prod.properties, group=CAS, namespace=cas)
# 去掉 /cas 上下文路径,对外域名走 sso.yunhaixingtong.com
cas.server.name: http://sso.yunhaixingtong.com
cas.server.prefix: http://sso.yunhaixingtong.com
cas.adminPagesSecurity.ip: 127\.0\.0\.1
logging.config: file:/etc/cas/config/log4j2.xml
server.port: 8443
server.context-path: /
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
# TGC 经 caddy(HTTP) 透传,关闭 Secure 以便浏览器在 http 下也能存 cookie
cas.tgc.secure: false
# 上下文路径已改为 /,cookie 作用域必须同步为 /,否则 SSO 票据校验拿不到 TGC
cas.tgc.path: /
# ===== 数据库认证(自定义 DbAuthenticationHandler 读取)=====
cas.authn.jdbc.query[0].sql: select * from sys_user where username=?
cas.authn.jdbc.query[0].url: jdbc:mysql://mysql:3306/cas?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
cas.authn.jdbc.query[0].user: cas
cas.authn.jdbc.query[0].password: Cas@123456
cas.authn.jdbc.query[0].driverClass: com.mysql.jdbc.Driver
cas.authn.jdbc.query[0].fieldPassword: password
cas.authn.jdbc.query[0].fieldDisabled: disabled
cas.authn.jdbc.query[0].fieldExpired: expired
cas.authn.jdbc.query[0].url=jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
cas.authn.jdbc.query[0].user=root
cas.authn.jdbc.query[0].password=CVf^x$sW*b6CGHxh
cas.authn.jdbc.query[0].driverClass=com.mysql.jdbc.Driver
spring.redis.host=redis-server
spring.redis.port=6379
spring.redis.password=wv&WcDYAmLtqTIGM
spring.redis.database=0
spring.redis.timeout=5000
spring.redis.ssl=false
spring.redis.jedis.pool.max-active=8
spring.redis.jedis.pool.max-idle=8
spring.redis.jedis.pool.min-idle=0
spring.redis.jedis.pool.max-wait=3000
spring.rabbitmq.host=rabbitmq
spring.rabbitmq.port=5672
spring.rabbitmq.username=admin
spring.rabbitmq.password=JdU29I#Q2E4yXoUl
spring.rabbitmq.virtual-host=/
=== JDBC url in container ===
cas.authn.jdbc.query[0].sql: select * from sys_user where username=?
cas.authn.jdbc.query[0].url: jdbc:mysql://mysql:3306/cas?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
cas.authn.jdbc.query[0].user: cas
cas.authn.jdbc.query[0].password: Cas@123456
cas.authn.jdbc.query[0].driverClass: com.mysql.jdbc.Driver
cas.authn.jdbc.query[0].fieldPassword: password
cas.authn.jdbc.query[0].fieldDisabled: disabled
cas.authn.jdbc.query[0].fieldExpired: expired
cas.authn.jdbc.query[0].url=jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
cas.authn.jdbc.query[0].user=root
cas.authn.jdbc.query[0].password=CVf^x$sW*b6CGHxh
cas.authn.jdbc.query[0].driverClass=com.mysql.jdbc.Driver
+40
View File
@@ -0,0 +1,40 @@
# CAS 服务端编排:仅 cas 应用。
# MySQL / Redis / RabbitMQ 均为 infra 公共实例(mysql8 等),不在本编排内自建。
# CAS 启动时由 run-cas.sh 从 Nacos 拉取:
# - 公共基础配置 common.properties (namespace=public)
# - CAS 自身配置 cas-prod.properties (namespace=cas)
# 启动:cd /opt/docker/apps/cas-sso && docker compose -f docker-compose.cas.yml up -d
services:
cas:
image: ${CAS_IMAGE:-registry.cn-hangzhou.aliyuncs.com/damaomi_docker/yunhai:${VERSION:-v1.3}}
container_name: cas-sso
restart: unless-stopped
networks:
- infra-infra-net
environment:
NACOS_ADDR: nacos:8848
NACOS_USER: nacos
NACOS_PASS: nacos
# CAS 自身配置
CAS_CONFIG_DATA_ID: cas-prod.properties
CAS_CONFIG_GROUP: CAS
CAS_CONFIG_NAMESPACE: cas
# 公共基础配置(含 mysql / redis / rabbitmq 连接)
CAS_BASE_CONFIG_DATA_ID: common.properties
CAS_BASE_CONFIG_GROUP: DEFAULT_GROUP
CAS_BASE_CONFIG_NAMESPACE: public
# 用宿主机(部署目录)最新的 run-cas.sh 覆盖镜像内旧版本,避免每次改脚本都重新构建镜像
# 注意:cas.war 不再挂载,直接烤进镜像(由 build-push.sh 重建),避免热修包被 recreate 冲掉/覆盖导致不可预期的问题
volumes:
- ./run-cas.sh:/app/run-cas.sh:ro
ports:
- "8443:8443"
healthcheck:
test: ["CMD", "curl", "-fk", "https://localhost:8443/login"]
interval: 30s
timeout: 10s
retries: 5
networks:
infra-infra-net:
external: true
+11
View File
@@ -0,0 +1,11 @@
import paramiko
ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20)
def run(c):
i,o,e=ssh.exec_command(c); return o.read().decode('utf-8','ignore')+("ERR:"+e.read().decode('utf-8','ignore') if e.read() else "")
out=run("docker logs --tail 200 cas-sso 2>&1 | grep -n -A 25 '建立 SSO 会话失败\\|createTicketGrantingTicket\\|AbstractTicketException\\|AuthenticationException\\|Cannot\\|Exception' | tail -60")
print(out if out.strip() else "(no exception traces found)")
# also check redis ticket registry config
print("\n=== redis ticket registry config? ===")
print(run("docker exec cas-sso sh -c 'grep -ri ticket.registry /etc/cas/config/cas.properties 2>/dev/null; grep -ri ticket.registry /app/*.properties 2>/dev/null' || echo none"))
ssh.close()
+15
View File
@@ -0,0 +1,15 @@
import paramiko
ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20)
def run(c):
i,o,e=ssh.exec_command(c); return o.read().decode('utf-8','ignore')+("ERR:"+e.read().decode('utf-8','ignore') if e.read() else "")
print("=== 1) env vars containing REDIRECT / CUSTOM / CAS_LOGIN ===")
print(run("docker exec cas-sso env 2>/dev/null | grep -iE 'REDIRECT|CUSTOM|LOGIN|CAS_SERVER' "))
print("\n=== 2) full grep default-redirect-url in container FS ===")
print(run("docker exec cas-sso grep -rni 'default-redirect-url' /etc/cas /app 2>/dev/null | head"))
print("\n=== 3) run-cas.sh: how is cas.properties built? any hardcode of redirect/cas/login ===")
print(run("docker exec cas-sso grep -niE 'redirect|default-redirect|cas/login|cas.properties|NACOS|dataId|cat >' /app/run-cas.sh 2>/dev/null | head -40"))
print("\n=== 4) the effective cas.properties custom.* block ===")
print(run("docker exec cas-sso sh -c 'grep -niE \"^custom|^cas\" /etc/cas/config/cas.properties 2>/dev/null | head -40'"))
ssh.close()
+19
View File
@@ -0,0 +1,19 @@
-- CAS 专用 MySQL 初始化(仅首次启动执行一次)
-- 库/用户由容器环境变量 MYSQL_DATABASE / MYSQL_USER / MYSQL_PASSWORD 创建,这里只建表与种子数据
CREATE DATABASE IF NOT EXISTS cas CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE cas;
CREATE TABLE IF NOT EXISTS sys_user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL,
password VARCHAR(100) NOT NULL,
disabled TINYINT(1) DEFAULT 0,
expired TINYINT(1) DEFAULT 0,
UNIQUE KEY uk_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 默认管理员:admin / admin123 (BCrypt,前缀 $2a$ 与 Spring Security 兼容)
INSERT INTO sys_user(username, password, disabled, expired) VALUES
('admin', '$2a$10$kRc0qMMC9NGbE.46fEWZvOlpcy.pjKef2ZA9yoFdKFfBw.nEJEUZO', 0, 0)
ON DUPLICATE KEY UPDATE password = VALUES(password);
+38
View File
@@ -0,0 +1,38 @@
import zipfile, os, shutil
PROJ = "D:/SoftProject/yunhai/CasSso/cas-overlay-template-5.3"
WAR = os.path.join(PROJ, "docker/cas.war")
# (entry_name_in_war, local_source_file)
REPL = [
("WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class",
os.path.join(PROJ, "target/cas/WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class")),
("WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController$LocalAuthenticationResult.class",
os.path.join(PROJ, "target/cas/WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController$LocalAuthenticationResult.class")),
("front/index.html",
os.path.join(PROJ, "src/main/webapp/front/index.html")),
]
repl_map = {name: open(src, "rb").read() for name, src in REPL}
tmp = WAR + ".tmp"
zin = zipfile.ZipFile(WAR)
zout = zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED)
for item in zin.infolist():
if item.filename in repl_map:
zout.writestr(item, repl_map[item.filename])
else:
zout.writestr(item, zin.read(item.filename))
zin.close()
zout.close()
shutil.move(tmp, WAR)
z = zipfile.ZipFile(WAR)
print("testzip:", z.testzip())
html = z.read("front/index.html").decode("utf-8", "replace")
print("has pwdMgrForm:", "pwdMgrForm" in html)
print("has name=\"username\":", 'name="username"' in html)
print("has name=\"password\":", 'name="password"' in html)
cls = z.read("WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class")
print("default url in class:", b"front/index.html" in cls)
print("entries replaced OK")
+47
View File
@@ -0,0 +1,47 @@
import zipfile, os, shutil
WAR = "docker/cas.war"
CLS_DIR = "target/cas/WEB-INF/classes/org/apereo/cas/web/custom/controller"
SRC_INDEX = "src/main/webapp/front/index.html"
IDX_ENTRY = "front/index.html"
files = {
os.path.join(CLS_DIR, "LoginController.class"): "WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class",
os.path.join(CLS_DIR, "LoginController$LocalAuthenticationResult.class"): "WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController$LocalAuthenticationResult.class",
SRC_INDEX: IDX_ENTRY,
"src/main/resources/application.yml": "WEB-INF/classes/application.yml",
}
tmp = WAR + ".tmp"
zin = zipfile.ZipFile(WAR)
zout = zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED)
for item in zin.infolist():
if item.filename in files.values():
continue
zout.writestr(item, zin.read(item.filename))
zin.close()
for src, entry in files.items():
data = open(src, "rb").read()
# 用原条目元数据保持权限/时间戳
zi = zipfile.ZipInfo(entry)
zi.date_time = (2026, 8, 3, 18, 10, 0)
zi.compress_type = zipfile.ZIP_DEFLATED
zi.external_attr = 0o644 << 16
zout.writestr(zi, data)
zout.close()
os.replace(tmp, WAR)
# 校验
z = zipfile.ZipFile(WAR)
print("testzip:", z.testzip())
idx = z.read(IDX_ENTRY).decode("utf-8", "replace")
cls = z.read("WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class").decode("latin1")
yml = z.read("WEB-INF/classes/application.yml").decode("utf-8", "replace")
print("index.html has ?v=1.8:", "?v=1.8" in idx)
print("index.html has real form action /api/login:", 'action="/api/login"' in idx)
print("index.html has loginname hidden:", 'name="loginname"' in idx)
print("class has window.location.href (v1.8 success):", "window.location.href" in cls)
print("class has guard /cas/login fallback:", 'contains("/cas/login")' in cls)
yml_ok = "default-redirect-url: /front/index.html" in yml and "default-redirect-url: /cas/login" not in yml
print("application.yml default-redirect-url fixed (/front/index.html):", yml_ok)
print("WAR size:", os.path.getsize(WAR))
+51
View File
@@ -0,0 +1,51 @@
import urllib.request, ssl, paramiko, json, base64, bcrypt, socket
from cryptography.hazmat.primitives.serialization import load_der_public_key
from cryptography.hazmat.primitives.asymmetric import padding
BASE="http://sso.yunhaixingtong.com"; HOST="sso.yunhaixingtong.com"; PORT=80
ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
PLAIN="SsoTest@2026"
h=bcrypt.hashpw(PLAIN.encode(), bcrypt.gensalt(rounds=10)).decode()
if h.startswith("$2b$"): h=h.replace("$2b$","$2a$",1)
USER="ssotest_tmp"
ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20)
def run(c):
i,o,e=ssh.exec_command(c); return o.read().decode('utf-8','ignore')+("ERR:"+e.read().decode('utf-8','ignore') if e.read() else "")
PWD="CVf^x$sW*b6CGHxh"
# write SQL via sftp (avoid shell $ expansion of the BCrypt hash)
seed=f"DELETE FROM sys_user WHERE username='{USER}';\nINSERT INTO sys_user(username,create_time,create_user,password,disabled,expired) VALUES('{USER}',NOW(),0,'{h}',0,0);\n"
sftp=ssh.open_sftp(); sftp.open('/tmp/seed.sql','w').write(seed); sftp.close()
print("seed.sql:\n", seed)
print("import result:", run(f"docker exec -i mysql8 mysql -uroot -p'{PWD}' bsch_userbase < /tmp/seed.sql 2>&1").strip())
pub=json.loads(urllib.request.urlopen(urllib.request.Request(BASE+"/api/public-key"),timeout=20,context=ctx).read().decode())["data"]["publicKey"]
rsa_pub=load_der_public_key(base64.b64decode(pub))
def rsa_enc(s): return base64.b64encode(rsa_pub.encrypt(s.encode(), padding.PKCS1v15())).decode()
def raw_post(extra=None):
body=f"loginname={rsa_enc(USER)}&pwd={rsa_enc(PLAIN)}"
hh={"Host":HOST,"Content-Type":"application/x-www-form-urlencoded","Content-Length":str(len(body))}
if extra: hh.update(extra)
req="POST /api/login HTTP/1.1\r\n"+"\r\n".join(f"{k}: {v}" for k,v in hh.items())+"\r\nConnection: close\r\n\r\n"+body
s=socket.create_connection((HOST,PORT),timeout=20); s.sendall(req.encode()); data=b""
while True:
c=s.recv(4096)
if not c: break
data+=c
s.close(); return data.decode('utf-8','ignore')
print("\n=== SUCCESS login, NATIVE FORM (no XHR) ===")
r=raw_post(); print(r.split("\r\n\r\n",1)[0][:1000]); print("BODY:", r.split("\r\n\r\n",1)[1][:300] if "\r\n\r\n" in r else "(no body)")
print("\n=== SUCCESS login, AJAX (X-Requested-With) ===")
r2=raw_post({"X-Requested-With":"XMLHttpRequest","Accept":"application/json"})
print(r2.split("\r\n\r\n",1)[0][:400]); print("BODY:", r2.split("\r\n\r\n",1)[1][:200] if "\r\n\r\n" in r2 else "")
# cleanup
sftp=ssh.open_sftp(); sftp.open('/tmp/del.sql','w').write(f"DELETE FROM sys_user WHERE username='{USER}';\n"); sftp.close()
run(f"docker exec -i mysql8 mysql -uroot -p'{PWD}' bsch_userbase < /tmp/del.sql 2>&1")
print("\ncleanup done")
ssh.close()
+65
View File
@@ -0,0 +1,65 @@
import urllib.request, ssl, paramiko, json, base64, bcrypt
from cryptography.hazmat.primitives.serialization import load_der_public_key
from cryptography.hazmat.primitives.asymmetric import padding
BASE="http://sso.yunhaixingtong.com"
HOST="sso.yunhaixingtong.com"; PORT=80
ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
# ---- 1) known password + BCrypt hash (strength 10, prefix $2a$ to match Spring) ----
PLAIN="SsoTest@2026"
h=bcrypt.hashpw(PLAIN.encode(), bcrypt.gensalt(rounds=10)).decode()
if h.startswith("$2b$"): h=h.replace("$2b$","$2a$",1)
print("BCrypt hash:", h)
# ---- 2) insert temp user into bsch_userbase ----
ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20)
def run(c):
i,o,e=ssh.exec_command(c); return o.read().decode('utf-8','ignore')+("ERR:"+e.read().decode('utf-8','ignore') if e.read() else "")
PWD="CVf^x$sW*b6CGHxh"
USER="ssotest_tmp"
run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e \"DELETE FROM sys_user WHERE username='{USER}';\" 2>&1")
ins=run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e \"INSERT INTO sys_user(username,create_time,create_user,password,disabled,expired) VALUES('{USER}',NOW(),0,'{h}',0,0);\" 2>&1")
print("INSERT result:", ins.strip())
# ---- 3) RSA public key ----
pub=json.loads(urllib.request.urlopen(urllib.request.Request(BASE+"/api/public-key"),timeout=20,context=ctx).read().decode())["data"]["publicKey"]
der=base64.b64decode(pub)
rsa_pub=load_der_public_key(der)
def rsa_enc(s):
return base64.b64encode(rsa_pub.encrypt(s.encode(), padding.PKCS1v15())).decode()
# ---- 4) raw socket POST (native form, NO X-Requested-With) ----
def raw_post(headers_extra=None):
body=f"loginname={rsa_enc(USER)}&pwd={rsa_enc(PLAIN)}"
h={"Host":HOST,"Content-Type":"application/x-www-form-urlencoded",
"Content-Length":str(len(body))}
if headers_extra: h.update(headers_extra)
req="POST /api/login HTTP/1.1\r\n"+"\r\n".join(f"{k}: {v}" for k,v in h.items())+"\r\nConnection: close\r\n\r\n"+body
s=socket_create(); s.sendall(req.encode())
data=b""
while True:
c=s.recv(4096)
if not c: break
data+=c
s.close()
return data.decode('utf-8','ignore')
import socket
def socket_create(): return socket.create_connection((HOST,PORT),timeout=20)
print("\n=== SUCCESS login, NATIVE FORM (no XHR) -> expect 200 HTML w/ window.location.href ===")
resp=raw_post()
head=resp.split("\r\n\r\n",1)[0]
print(head[:1200])
print("\n=== SUCCESS login, AJAX (X-Requested-With) -> expect JSON {code:0,data:{url}} ===")
resp2=raw_post({"X-Requested-With":"XMLHttpRequest","Accept":"application/json"})
head2=resp2.split("\r\n\r\n",1)[0]
print(head2[:800])
# cleanup
run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e \"DELETE FROM sys_user WHERE username='{USER}';\" 2>&1")
print("\ncleanup done")
ssh.close()
+60
View File
@@ -0,0 +1,60 @@
import urllib.request, json, base64, ssl
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
from cryptography.hazmat.primitives.serialization import load_pem_public_key
HOST_HDR = "sso.yunhaixingtong.com"
BASE = "http://192.168.10.157:80"
def req(method, path, data=None, headers=None):
h = {"Host": HOST_HDR}
if headers: h.update(headers)
if data is not None:
data = urllib.parse.urlencode(data).encode()
h["Content-Type"] = "application/x-www-form-urlencoded"
r = urllib.request.Request(BASE+path, data=data, headers=h, method=method)
try:
resp = urllib.request.urlopen(r, timeout=20)
body = resp.read().decode("utf-8","replace")
return resp.status, dict(resp.getheaders()), body
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), e.read().decode("utf-8","replace")
# 1) 取公钥
st, hd, body = req("GET", "/api/public-key")
print("=== /api/public-key ===")
print("status", st)
print("body", body[:600])
pk = json.loads(body)["data"]["publicKey"]
print("publicKey len", len(pk))
print("publicKey head:", pk[:60])
# 重组为合法 PEM(端点把空白去掉了,补回换行)
b64 = pk.replace("-----BEGIN PUBLIC KEY-----","").replace("-----END PUBLIC KEY-----","").replace("-----BEGIN RSA PUBLIC KEY-----","").replace("-----END RSA PUBLIC KEY-----","").replace("\n","").replace(" ","")
pem = "-----BEGIN PUBLIC KEY-----\n" + "\n".join(b64[i:i+64] for i in range(0,len(b64),64)) + "\n-----END PUBLIC KEY-----"
pub = load_pem_public_key(pem.encode())
def enc(pt):
ct = pub.encrypt(pt.encode("utf-8"), asym_padding.PKCS1v15())
return base64.b64encode(ct).decode()
# 用户名/密码:用已知可登录的 yunhaisch(密码需正确才能成功;若不知道正确密码,可先看错误分支)
USERNAME = "yunhaisch"
PASSWORD = input("请输入 yunhaisch 的密码(仅本地测试用,不会外发): ").strip()
euser = enc(USERNAME)
epwd = enc(PASSWORD)
# 2) 登录(不带 service)
st, hd, body = req("POST", "/api/login", data={
"loginname": euser, "pwd": epwd
})
print("\n=== POST /api/login (无 service) ===")
print("status", st)
print("Set-Cookie:", hd.get("Set-Cookie"))
print("body", body[:800])
try:
j = json.loads(body)
print(">>> code=", j.get("code"), "msg=", j.get("msg"))
print(">>> data.url=", (j.get("data") or {}).get("url"))
except Exception as e:
print("parse err", e)
+50
View File
@@ -0,0 +1,50 @@
import urllib.request, ssl, paramiko, json, base64, bcrypt, socket
from cryptography.hazmat.primitives.serialization import load_der_public_key
from cryptography.hazmat.primitives.asymmetric import padding
BASE="http://sso.yunhaixingtong.com"; HOST="sso.yunhaixingtong.com"; PORT=80
ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
PLAIN="SsoTest@2026"
h=bcrypt.hashpw(PLAIN.encode(), bcrypt.gensalt(rounds=10)).decode()
if h.startswith("$2b$"): h=h.replace("$2b$","$2a$",1)
USER="ssotest_tmp"
UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20)
def run(c):
i,o,e=ssh.exec_command(c); return o.read().decode('utf-8','ignore')+("ERR:"+e.read().decode('utf-8','ignore') if e.read() else "")
PWD="CVf^x$sW*b6CGHxh"
seed=f"DELETE FROM sys_user WHERE username='{USER}';\nINSERT INTO sys_user(username,create_time,create_user,password,disabled,expired) VALUES('{USER}',NOW(),0,'{h}',0,0);\n"
sftp=ssh.open_sftp(); sftp.open('/tmp/seed.sql','w').write(seed); sftp.close()
run(f"docker exec -i mysql8 mysql -uroot -p'{PWD}' bsch_userbase < /tmp/seed.sql 2>&1")
pub=json.loads(urllib.request.urlopen(urllib.request.Request(BASE+"/api/public-key"),timeout=20,context=ctx).read().decode())["data"]["publicKey"]
rsa_pub=load_der_public_key(base64.b64decode(pub))
def rsa_enc(s): return base64.b64encode(rsa_pub.encrypt(s.encode(), padding.PKCS1v15())).decode()
def raw_post(extra=None):
body=f"loginname={rsa_enc(USER)}&pwd={rsa_enc(PLAIN)}"
hh={"Host":HOST,"User-Agent":UA,"Content-Type":"application/x-www-form-urlencoded","Content-Length":str(len(body))}
if extra: hh.update(extra)
req="POST /api/login HTTP/1.1\r\n"+"\r\n".join(f"{k}: {v}" for k,v in hh.items())+"\r\nConnection: close\r\n\r\n"+body
s=socket.create_connection((HOST,PORT),timeout=20); s.sendall(req.encode()); data=b""
while True:
c=s.recv(4096)
if not c: break
data+=c
s.close(); return data.decode('utf-8','ignore')
print("=== SUCCESS login w/ User-Agent, NATIVE FORM ===")
r=raw_post()
print("HEAD:\n", r.split("\r\n\r\n",1)[0][:700])
print("BODY:\n", r.split("\r\n\r\n",1)[1][:400] if "\r\n\r\n" in r else "(no body)")
print("\n=== SUCCESS login w/ User-Agent, AJAX ===")
r2=raw_post({"X-Requested-With":"XMLHttpRequest","Accept":"application/json"})
print("HEAD:\n", r2.split("\r\n\r\n",1)[0][:400])
print("BODY:\n", r2.split("\r\n\r\n",1)[1][:300] if "\r\n\r\n" in r2 else "")
sftp=ssh.open_sftp(); sftp.open('/tmp/del.sql','w').write(f"DELETE FROM sys_user WHERE username='{USER}';\n"); sftp.close()
run(f"docker exec -i mysql8 mysql -uroot -p'{PWD}' bsch_userbase < /tmp/del.sql 2>&1")
print("\ncleanup done"); ssh.close()
+37
View File
@@ -0,0 +1,37 @@
import urllib.request, urllib.error, ssl
BASE = "http://sso.yunhaixingtong.com"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def req(method, path, data=None, headers=None):
url = BASE + path
h = headers or {}
r = urllib.request.Request(url, data=data, method=method, headers=h)
try:
resp = urllib.request.urlopen(r, timeout=20, context=ctx)
body = resp.read().decode('utf-8', 'ignore')
return resp.status, dict(resp.getheaders()), body
except urllib.error.HTTPError as e:
body = e.read().decode('utf-8', 'ignore')
return e.code, dict(e.headers), body
print("=== 1) /front/index.html WITHOUT auth (is it behind CAS?) ===")
st, hd, body = req("GET", "/front/index.html")
print("status:", st, "| Location:", hd.get("Location"))
print("body has /cas/login:", "/cas/login" in body, "| ?v=1.7:", "v=1.7" in body,
"| action=/api/login:", 'action="/api/login"' in body, "| name=username:", 'name="username"' in body)
print("\n=== 2) POST /api/login BOGUS (native form, no XHR header) ===")
form = "loginname=AAA&pwd=BBB&captcha=".encode()
st, hd, body = req("POST", "/api/login", data=form, headers={"Content-Type":"application/x-www-form-urlencoded"})
print("status:", st, "| Location:", hd.get("Location"))
print("\n=== 3) POST /api/login BOGUS (AJAX X-Requested-With) ===")
st, hd, body = req("POST", "/api/login", data=form, headers={"Content-Type":"application/x-www-form-urlencoded","X-Requested-With":"XMLHttpRequest"})
print("status:", st, "| body[:180]:", body[:180])
print("\n=== 4) /api/public-key ===")
st, hd, body = req("GET", "/api/public-key")
print("status:", st, "| body[:160]:", body[:160])
+39
View File
@@ -0,0 +1,39 @@
import urllib.request, ssl, paramiko, json, base64, bcrypt, socket
from cryptography.hazmat.primitives.serialization import load_der_public_key
from cryptography.hazmat.primitives.asymmetric import padding
BASE="http://sso.yunhaixingtong.com"; HOST="sso.yunhaixingtong.com"; PORT=80
ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
PLAIN="SsoTest@2026"
h=bcrypt.hashpw(PLAIN.encode(), bcrypt.gensalt(rounds=10)).decode()
if h.startswith("$2b$"): h=h.replace("$2b$","$2a$",1)
print("hash self-check (python):", bcrypt.checkpw(PLAIN.encode(), h.encode()))
ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20)
def run(c):
i,o,e=ssh.exec_command(c); return o.read().decode('utf-8','ignore')+("ERR:"+e.read().decode('utf-8','ignore') if e.read() else "")
PWD="CVf^x$sW*b6CGHxh"; USER="ssotest_tmp"
run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e \"DELETE FROM sys_user WHERE username='{USER}'; INSERT INTO sys_user(username,create_time,create_user,password,disabled,expired) VALUES('{USER}',NOW(),0,'{h}',0,0);\" 2>&1")
pub=json.loads(urllib.request.urlopen(urllib.request.Request(BASE+"/api/public-key"),timeout=20,context=ctx).read().decode())["data"]["publicKey"]
rsa_pub=load_der_public_key(base64.b64decode(pub))
def rsa_enc(s): return base64.b64encode(rsa_pub.encrypt(s.encode(), padding.PKCS1v15())).decode()
body=f"loginname={rsa_enc(USER)}&pwd={rsa_enc(PLAIN)}"
req="POST /api/login HTTP/1.1\r\nHost: %s\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s"%(HOST,len(body),body)
s=socket.create_connection((HOST,PORT),timeout=20); s.sendall(req.encode())
data=b""
while True:
c=s.recv(4096)
if not c: break
data+=c
s.close()
print("\nRESPONSE HEAD:\n", data.decode('utf-8','ignore').split("\r\n\r\n",1)[0][:600])
# fetch logs mentioning ssotest_tmp
print("\n=== CAS logs (ssotest_tmp) ===")
logs=run("docker logs --tail 60 cas-sso 2>&1 | grep -i 'ssotest_tmp\\|尝试登录\\|密码错误\\|Padding\\|Decryption\\|Illegal' | tail -20")
print(logs if logs.strip() else "(no matching lines)")
run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e \"DELETE FROM sys_user WHERE username='{USER}';\" 2>&1")
ssh.close()
+22
View File
@@ -0,0 +1,22 @@
import urllib.request, ssl, paramiko, json
BASE="http://sso.yunhaixingtong.com"
ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
# 1) live RSA public key
r=urllib.request.urlopen(urllib.request.Request(BASE+"/api/public-key"), timeout=20, context=ctx)
pub=json.loads(r.read().decode())["data"]["publicKey"]
print("PUBLIC_KEY_LEN:", len(pub))
print("PUBLIC_KEY_HEAD:", pub[:40], "...")
# 2) sys_user schema + sample usernames (no passwords)
ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20)
def run(c):
i,o,e=ssh.exec_command(c); return o.read().decode('utf-8','ignore')+("ERR:"+e.read().decode('utf-8','ignore') if e.read() else "")
PWD="CVf^x$sW*b6CGHxh"
print("\n=== DESCRIBE sys_user ===")
print(run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e 'DESCRIBE sys_user;' 2>&1 | head -40"))
print("\n=== sample usernames (limit 10) ===")
print(run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e 'SELECT username, disabled, expired FROM sys_user LIMIT 10;' 2>&1 | head -20"))
ssh.close()
+58
View File
@@ -0,0 +1,58 @@
import urllib.request, ssl, paramiko, json, base64, bcrypt, socket, time, re
from cryptography.hazmat.primitives.serialization import load_der_public_key
from cryptography.hazmat.primitives.asymmetric import padding
BASE="http://sso.yunhaixingtong.com"; HOST="sso.yunhaixingtong.com"; PORT=80
ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
PLAIN="SsoTest@2026"
h=bcrypt.hashpw(PLAIN.encode(), bcrypt.gensalt(rounds=10)).decode()
if h.startswith("$2b$"): h=h.replace("$2b$","$2a$",1)
USER="ssotest_tmp"; UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36"
ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20)
def run(c):
i,o,e=ssh.exec_command(c); out=o.read().decode('utf-8','ignore'); err=e.read().decode('utf-8','ignore'); return out+("ERR:"+err if err else "")
PWD="CVf^x$sW*b6CGHxh"
seed=f"DELETE FROM sys_user WHERE username='{USER}'; INSERT INTO sys_user(username,create_time,create_user,password,disabled,expired) VALUES('{USER}',NOW(),0,'{h}',0,0);"
sftp=ssh.open_sftp(); sftp.open('/tmp/seed.sql','w').write(seed); sftp.close()
print("seed:", run(f"docker exec -i mysql8 mysql -uroot -p'{PWD}' bsch_userbase < /tmp/seed.sql 2>&1").strip())
def raw_post(extra=None):
pub=json.loads(urllib.request.urlopen(urllib.request.Request(BASE+"/api/public-key"),timeout=20,context=ctx).read().decode())["data"]["publicKey"]
rsa_pub=load_der_public_key(base64.b64decode(pub))
def enc(s): return base64.b64encode(rsa_pub.encrypt(s.encode(), padding.PKCS1v15())).decode()
body=f"loginname={enc(USER)}&pwd={enc(PLAIN)}"
hh={"Host":HOST,"User-Agent":UA,"Content-Type":"application/x-www-form-urlencoded","Content-Length":str(len(body))}
if extra: hh.update(extra)
req="POST /api/login HTTP/1.1\r\n"+"\r\n".join(f"{k}: {v}" for k,v in hh.items())+"\r\nConnection: close\r\n\r\n"+body
s=socket.create_connection((HOST,PORT),timeout=20); s.sendall(req.encode()); data=b""
while True:
c=s.recv(4096)
if not c: break
data+=c
s.close(); return data.decode('utf-8','ignore')
for _ in range(20):
try:
r=raw_post({"X-Requested-With":"XMLHttpRequest","Accept":"application/json"})
if "200 OK" in r.split("\r\n\r\n",1)[0]: break
except Exception: pass
time.sleep(6)
print("\n=== AJAX (what the page SHOULD use) ===")
r=raw_post({"X-Requested-With":"XMLHttpRequest","Accept":"application/json"})
body=r.split("\r\n\r\n",1)[1] if "\r\n\r\n" in r else ""
print("RAW BODY:", body[:300])
m=re.search(r'"url"\s*:\s*"([^"]*)"', body)
print("-> data.url:", m.group(1) if m else "NONE / NOT PRESENT")
print("\n=== NATIVE FORM (what index.html currently does) ===")
r2=raw_post()
head=r2.split("\r\n\r\n",1)[0]; body2=r2.split("\r\n\r\n",1)[1] if "\r\n\r\n" in r2 else ""
print("STATUS LINE:", head.split("\r\n",1)[0])
print("BODY[:400]:", body2[:400])
sftp=ssh.open_sftp(); sftp.open('/tmp/del.sql','w').write(f"DELETE FROM sys_user WHERE username='{USER}';\n"); sftp.close()
print("cleanup:", run(f"docker exec -i mysql8 mysql -uroot -p'{PWD}' bsch_userbase < /tmp/del.sql 2>&1").strip())
ssh.close()
+25
View File
@@ -0,0 +1,25 @@
import socket
HOST="sso.yunhaixingtong.com"; PORT=80
body="loginname=AAA&pwd=BBB&captcha="
req=(
"POST /api/login HTTP/1.1\r\n"
f"Host: {HOST}\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n"
"\r\n"
+ body
)
s=socket.create_connection((HOST,PORT),timeout=20)
s.sendall(req.encode())
data=b""
while True:
chunk=s.recv(4096)
if not chunk: break
data+=chunk
s.close()
text=data.decode('utf-8','ignore')
# print status line + headers (up to first blank line)
head=text.split("\r\n\r\n",1)[0]
print("=== RAW RESPONSE HEAD (native form POST) ===")
print(head[:1500])
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
# 通过 SSH 在远端以 sudo 执行脚本(从 stdin 读取)
# 用法: python3 remote_run.py <host> <port> <user> < script.sh
# 依赖环境变量 SSHPASS
import os, sys, paramiko
def main():
host = sys.argv[1]
port = int(sys.argv[2]) if len(sys.argv) > 2 else 22
user = sys.argv[3] if len(sys.argv) > 3 else "root"
pw = os.environ.get("SSHPASS")
if not pw:
sys.stderr.write("ERROR: 请先设置环境变量 SSHPASS\n")
sys.exit(1)
script = sys.stdin.read()
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(host, port, user, password=pw, timeout=30)
stdin, stdout, stderr = c.exec_command("sudo -S bash -s", timeout=1800)
stdin.write(pw + "\n")
stdin.write(script)
stdin.flush()
stdin.channel.shutdown_write()
sys.stdout.write(stdout.read().decode(errors="replace"))
err = stderr.read().decode(errors="replace")
if err.strip():
sys.stderr.write("STDERR:\n" + err)
c.close()
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
# 将重编译后的 LoginController.class + 修复后的前端文件注入好包 target/cas.war
# 其余条目原样保留,确保 WAR 结构/压缩方式不变。
import os, zipfile, shutil
ROOT = r"D:\SoftProject\yunhai\CasSso\cas-overlay-template-5.3"
BASE = os.path.join(ROOT, "target", "cas.war")
NEW = os.path.join(ROOT, "target", "cas.war.new")
CLASSES_DIR = os.path.join(ROOT, "target", "cas", "WEB-INF", "classes",
"org", "apereo", "cas", "web", "custom", "controller")
FRONT_JS = os.path.join(ROOT, "src", "main", "webapp", "front", "js", "conf.js")
FRONT_IDX = os.path.join(ROOT, "src", "main", "webapp", "front", "index.html")
replace = {
"WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class":
os.path.join(CLASSES_DIR, "LoginController.class"),
"WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController$LocalAuthenticationResult.class":
os.path.join(CLASSES_DIR, "LoginController$LocalAuthenticationResult.class"),
"front/js/conf.js": FRONT_JS,
"front/index.html": FRONT_IDX,
}
# 校验待替换源文件存在
for k, v in replace.items():
if not os.path.isfile(v):
raise SystemExit("缺少源文件: %s -> %s" % (k, v))
with zipfile.ZipFile(BASE, "r") as z:
names = set(z.namelist())
# 确认条目存在(打印实际命中)
for k in replace:
print("命中" if k in names else "缺失(!)", k)
for k in list(replace):
if k not in names:
raise SystemExit("基包中找不到条目: " + k)
with zipfile.ZipFile(NEW, "w", zipfile.ZIP_DEFLATED) as out:
for info in z.infolist():
if info.filename in replace:
data = open(replace[info.filename], "rb").read()
ni = zipfile.ZipInfo(info.filename)
ni.date_time = info.date_time
ni.external_attr = info.external_attr
ni.compress_type = zipfile.ZIP_DEFLATED
out.writestr(ni, data)
print("替换:", info.filename, "->", len(data), "bytes")
else:
out.writestr(info, z.read(info.filename))
print("写盘:", NEW)
# 校验新包
with zipfile.ZipFile(NEW, "r") as z:
bad = z.testzip()
if bad:
raise SystemExit("WAR 损坏条目: " + bad)
nl = z.namelist()
assert "WEB-INF/lib/" in nl or any(n.startswith("WEB-INF/lib/") for n in nl), "缺少 WEB-INF/lib"
mf = z.read("META-INF/MANIFEST.MF").decode("utf-8", "replace")
print("MANIFEST Main-Class:", [l for l in mf.splitlines() if l.startswith("Main-Class")])
for k in replace:
print("存在" if k in nl else "丢失(!)", k)
# 原子替换
bak = BASE + ".bak7_31"
if not os.path.exists(bak):
shutil.move(BASE, bak)
shutil.move(NEW, BASE)
print("已生成新 target/cas.war, 旧包备份为 cas.war.bak7_31")
+130
View File
@@ -0,0 +1,130 @@
#!/bin/sh
# CAS 启动入口:
# 1) 从 Nacos 拉取「公共基础配置」(common.properties) 与「CAS 自身配置」(cas-prod.properties)
# 2) 用 common.properties 的 MySQL 连接 + cas-prod.properties 的 database 拼出完整 JDBC URL
# 3) 写出 /etc/cas/config/cas.properties 后启动 CAS
# 说明:CAS 不自建 MySQL,统一读 Nacos 公共配置;库名写在 CAS 自己的配置里。
set -e
CONFIG_PATH=/etc/cas/config/cas.properties
# 备份镜像内烤好的兜底配置,供 Nacos 不可用时回退
cp -f "$CONFIG_PATH" "${CONFIG_PATH}.baked" 2>/dev/null || true
NACOS_ADDR="${NACOS_ADDR:-nacos:8848}"
NACOS_USER="${NACOS_USER:-nacos}"
NACOS_PASS="${NACOS_PASS:-nacos}"
# 公共基础配置(含 mysql / redis / rabbitmq 连接信息)
BASE_DATA_ID="${CAS_BASE_CONFIG_DATA_ID:-common.properties}"
BASE_GROUP="${CAS_BASE_CONFIG_GROUP:-DEFAULT_GROUP}"
BASE_NS="${CAS_BASE_CONFIG_NAMESPACE:-public}"
# CAS 自身配置
CAS_DATA_ID="${CAS_CONFIG_DATA_ID:-cas-prod.properties}"
CAS_GROUP="${CAS_CONFIG_GROUP:-CAS}"
CAS_NS="${CAS_CONFIG_NAMESPACE:-cas}"
TOKEN=""
# 取 Nacos 登录 token(鉴权关闭时返回空,fetch 会自动降级)
get_token() {
TOKEN=$(curl -s -m 10 -X POST "http://${NACOS_ADDR}/nacos/v1/auth/login?username=${NACOS_USER}&password=${NACOS_PASS}" \
| sed -n 's/.*"accessToken":"\([^"]*\)".*/\1/p')
}
# 拉取单个配置到 $4;带 token 失败则自动不带 token 重试
fetch() {
local code
if [ -n "$TOKEN" ]; then
code=$(curl -s -m 10 -o "$4" -w "%{http_code}" "http://${NACOS_ADDR}/nacos/v1/cs/configs?dataId=$1&group=$2&tenant=$3&accessToken=${TOKEN}")
fi
if [ -z "$TOKEN" ] || [ "$code" != "200" ] || [ ! -s "$4" ]; then
code=$(curl -s -m 10 -o "$4" -w "%{http_code}" "http://${NACOS_ADDR}/nacos/v1/cs/configs?dataId=$1&group=$2&tenant=$3")
fi
if [ "$code" = "200" ] && [ -s "$4" ]; then
echo "[run-cas] 已加载 $1 (group=$2, ns=$3)"
return 0
fi
echo "[run-cas] 拉取 $1 失败 http=${code}"
return 1
}
# 精确读取 key=value 中的 value(兼容 value 内含 =)
val() {
awk -v k="$2" 'index($0,"=")>0 && substr($0,1,index($0,"=")-1)==k {print substr($0,index($0,"=")+1); exit}' "$1"
}
# 读键:优先 spring.datasource.*,回退 mysql.* 风格(兼容两种命名习惯)
readkey() {
local f="$1" k="$2" v
v=$(val "$f" "$k"); [ -n "$v" ] && { echo "$v"; return; }
v=$(val "$f" "mysql.${k#spring.datasource.}"); [ -n "$v" ] && { echo "$v"; return; }
[ "$k" = "spring.datasource.username" ] && val "$f" "mysql.user"
}
restore_baked() {
echo "[run-cas] 回退到镜像内烤好的默认配置"
cp -f "${CONFIG_PATH}.baked" "$CONFIG_PATH"
exec java -jar /app/cas.war
}
get_token
BASE_TMP="${CONFIG_PATH}.base"
CAS_TMP="${CONFIG_PATH}.cas"
# 1) 公共基础配置(MySQL 连接必须在此)
if ! fetch "$BASE_DATA_ID" "$BASE_GROUP" "$BASE_NS" "$BASE_TMP"; then
echo "[run-cas] 公共配置缺失,无法拼 JDBC,回退兜底"
restore_baked
fi
DS_HOST=$(readkey "$BASE_TMP" "spring.datasource.host")
DS_PORT=$(readkey "$BASE_TMP" "spring.datasource.port")
[ -z "$DS_PORT" ] && DS_PORT=3306
DS_USER=$(readkey "$BASE_TMP" "spring.datasource.username")
DS_PASS=$(readkey "$BASE_TMP" "spring.datasource.password")
DS_PARAMS=$(readkey "$BASE_TMP" "spring.datasource.params")
[ -z "$DS_PARAMS" ] && DS_PARAMS="useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
# 占位符保护:common.properties 未替换占位符时明确报错,避免静默用假凭据导致连不上 MySQL
case "${DS_USER}${DS_PASS}" in
*__SHARED_*) echo "[run-cas] 致命:common.properties 中 MySQL 用户名/密码仍为占位符(__SHARED_*),请在 Nacos 填入真实 mysql8 凭据后再启动"; exit 1 ;;
esac
# 2) CAS 自身配置(库名 database 在此;缺省回退 bsch_userbase)
DB_NAME=bsch_userbase
if fetch "$CAS_DATA_ID" "$CAS_GROUP" "$CAS_NS" "$CAS_TMP"; then
_db=$(val "$CAS_TMP" "cas.authn.jdbc.query[0].database")
[ -n "$_db" ] && DB_NAME="$_db"
else
echo "[run-cas] CAS 自身配置缺失,database 用默认 bsch_userbase"
fi
if [ -z "$DS_HOST" ]; then
echo "[run-cas] 公共配置缺少 MySQL host,无法拼 JDBC,回退兜底"
restore_baked
fi
# 3) 拼完整 JDBC URL
JDBC_URL="jdbc:mysql://${DS_HOST}:${DS_PORT}/${DB_NAME}?${DS_PARAMS}"
# 4) 合并:CAS 配置为主,过滤其中旧的 JDBC 连接项,注入公共连接
{
if [ -f "$CAS_TMP" ]; then
grep -vF 'cas.authn.jdbc.query[0].url=' "$CAS_TMP" \
| grep -vF 'cas.authn.jdbc.query[0].user=' \
| grep -vF 'cas.authn.jdbc.query[0].password=' \
| grep -vF 'cas.authn.jdbc.query[0].driverClass='
fi
echo "cas.authn.jdbc.query[0].url=${JDBC_URL}"
[ -n "$DS_USER" ] && echo "cas.authn.jdbc.query[0].user=${DS_USER}"
[ -n "$DS_PASS" ] && echo "cas.authn.jdbc.query[0].password=${DS_PASS}"
echo "cas.authn.jdbc.query[0].driverClass=com.mysql.jdbc.Driver"
# 透传公共配置里的 redis / rabbitmq 连接(自定义 /api/login 的 loginService 依赖 Redis 存验证码/失败计数/账号锁定)
grep -E '^spring\.redis\.' "$BASE_TMP" 2>/dev/null || true
grep -E '^spring\.rabbitmq\.' "$BASE_TMP" 2>/dev/null || true
} > "$CONFIG_PATH"
echo "[run-cas] 生效 JDBC URL: ${JDBC_URL}"
echo "[run-cas] 启动 CAS ..."
exec java -jar /app/cas.war
+28
View File
@@ -0,0 +1,28 @@
import paramiko
HOST = "192.168.10.157"; PORT = 22; USER = "yunhai"; PASS = "123456"
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(HOST, port=PORT, username=USER, password=PASS, timeout=20)
def run(cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
out = stdout.read().decode('utf-8', 'ignore')
err = stderr.read().decode('utf-8', 'ignore')
return out + ("ERR:" + err if err else "")
print("=== 1) running container image & status ===")
print(run("docker ps --filter name=cas-sso --format '{{.Image}} | {{.Status}}'"))
print("inspect image id:")
print(run("docker inspect cas-sso --format '{{.Image}}' 2>/dev/null"))
print("\n=== 2) effective cas.properties default-redirect-url (container) ===")
print(run("docker exec cas-sso grep -ri 'default-redirect-url' /etc/cas/config/ 2>/dev/null || echo 'NOT FOUND in /etc/cas/config'"))
print(run("docker exec cas-sso sh -c \"cat /etc/cas/config/cas.properties 2>/dev/null | grep -i redirect\" || echo none"))
print("\n=== 3) controller class feature in RUNNING war (v1.8 has window.location.href) ===")
print(run("docker cp cas-sso:/app/cas.war /tmp/live_cas.war 2>&1 && echo COPIED"))
# analyze with python on the box? simpler: grep for the literal bytecode string in the war zip entry
print(run("docker exec cas-sso sh -c \"cd /tmp && python3 - <<'PY'\nimport zipfile\nz=zipfile.ZipFile('/tmp/live_cas.war')\nc=z.read('WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class').decode('latin1')\nprint('has window.location.href :', 'window.location.href' in c)\nprint('has sendRedirect :', 'sendRedirect' in c)\nprint('has text/html :', 'text/html' in c)\nPY\n\" 2>&1 || echo 'python3 not on box'"))
ssh.close()
+36
View File
@@ -0,0 +1,36 @@
import paramiko, zipfile
HOST="192.168.10.157"; USER="yunhai"; PASS="123456"
LOCAL = "D:/SoftProject/yunhai/CasSso/cas-overlay-template-5.3/docker/live_cas.war"
ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(HOST, port=22, username=USER, password=PASS, timeout=20)
def run(c):
i,o,e=ssh.exec_command(c)
return o.read().decode('utf-8','ignore') + (("ERR:"+e.read().decode('utf-8','ignore')) if e.read() else "")
print(run("docker cp cas-sso:/app/cas.war /tmp/live_cas.war && echo COPIED_OK"))
sftp=ssh.open_sftp()
sftp.get('/tmp/live_cas.war', LOCAL)
sftp.close()
print("local war size:", __import__('os').path.getsize(LOCAL))
z=zipfile.ZipFile(LOCAL)
c=z.read('WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class').decode('latin1')
print("\n=== RUNNING WAR LoginController features ===")
print("has window.location.href (v1.8 success):", 'window.location.href' in c)
print("has sendRedirect (error path):", 'sendRedirect' in c)
print("has text/html (v1.8 success):", 'text/html' in c)
idx=z.read('front/index.html').decode('utf-8','replace')
print("\n=== RUNNING WAR index.html ===")
print("has action=/api/login:", 'action="/api/login"' in idx)
print("has ?v=1.8:", '?v=1.8' in idx)
print("has name=username:", 'name="username"' in idx)
print("\n=== container filesystem: any default-redirect-url? ===")
print(run("grep -rni 'default-redirect-url' /etc/cas 2>/dev/null; grep -rni 'default-redirect-url' /app/*.properties 2>/dev/null; echo '---env---'; docker exec cas-sso env 2>/dev/null | grep -i 'REDIRECT\\|TGC\\|CAS_CUSTOM' "))
print("\n=== effective cas.properties (head) ===")
print(run("docker exec cas-sso sh -c 'cat /etc/cas/config/cas.properties 2>/dev/null | head -60'"))
print("\n=== JDBC url in container ===")
print(run("docker exec cas-sso sh -c 'cat /etc/cas/config/cas.properties 2>/dev/null | grep -i jdbc'"))
ssh.close()
+8
View File
@@ -0,0 +1,8 @@
import paramiko
HOST="192.168.10.157"; PORT=22; USER="yunhai"; PASS="123456"
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(HOST, port=PORT, username=USER, password=PASS, timeout=20)
stdin, stdout, stderr = ssh.exec_command("docker logs --tail 80 cas-sso 2>&1 | tail -80")
print(stdout.read().decode('utf-8','ignore'))
ssh.close()
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
# 通过 SFTP 上传文件到远端(参数: host port user local remote [local remote ...])
# 依赖环境变量 SSHPASS
import os, sys, paramiko
def main():
host, port, user = sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 22, (sys.argv[3] if len(sys.argv) > 3 else "root")
pw = os.environ.get("SSHPASS")
if not pw:
sys.stderr.write("ERROR: 请先设置环境变量 SSHPASS\n"); sys.exit(1)
t = paramiko.Transport((host, port))
t.connect(username=user, password=pw)
sftp = paramiko.SFTPClient.from_transport(t)
args = sys.argv[4:]
for i in range(0, len(args), 2):
local, remote = args[i], args[i+1]
parts = [p for p in os.path.dirname(remote).split("/") if p]
cur = ""
for p in parts:
cur += "/" + p
try: sftp.stat(cur)
except IOError:
try: sftp.mkdir(cur)
except IOError: pass
sftp.put(local, remote)
print(f"PUT {local} -> {remote}")
t.close()
if __name__ == "__main__":
main()
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
cas.server.name: https://cas.example.org:8443
cas.server.prefix: https://cas.example.org:8443/cas
cas.adminPagesSecurity.ip=127\.0\.0\.1
logging.config: file:/etc/cas/config/log4j2.xml
+117
View File
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!-- Specify the refresh internal in seconds. -->
<Configuration monitorInterval="5" packages="org.apereo.cas.logging">
<Properties>
<!--
Default log directory is the current directory but that can be overridden with -Dcas.log.dir=<logdir>
Or you can change this property to a new default
-->
<Property name="cas.log.dir" >.</Property>
<!-- To see more CAS specific logging, adjust this property to info or debug or run server with -Dcas.log.leve=debug -->
<Property name="cas.log.level" >warn</Property>
</Properties>
<Appenders>
<Console name="console" target="SYSTEM_OUT">
<PatternLayout pattern="%d %p [%c] - &lt;%m&gt;%n"/>
</Console>
<RollingFile name="file" fileName="${sys:cas.log.dir}/cas.log" append="true"
filePattern="${sys:cas.log.dir}/cas-%d{yyyy-MM-dd-HH}-%i.log">
<PatternLayout pattern="%d %p [%c] - &lt;%m&gt;%n"/>
<Policies>
<OnStartupTriggeringPolicy />
<SizeBasedTriggeringPolicy size="10 MB"/>
<TimeBasedTriggeringPolicy />
</Policies>
</RollingFile>
<RollingFile name="auditlogfile" fileName="${sys:cas.log.dir}/cas_audit.log" append="true"
filePattern="${sys:cas.log.dir}/cas_audit-%d{yyyy-MM-dd-HH}-%i.log">
<PatternLayout pattern="%d %p [%c] - %m%n"/>
<Policies>
<OnStartupTriggeringPolicy />
<SizeBasedTriggeringPolicy size="10 MB"/>
<TimeBasedTriggeringPolicy />
</Policies>
</RollingFile>
<RollingFile name="perfFileAppender" fileName="${sys:cas.log.dir}/perfStats.log" append="true"
filePattern="${sys:cas.log.dir}/perfStats-%d{yyyy-MM-dd-HH}-%i.log">
<PatternLayout pattern="%m%n"/>
<Policies>
<OnStartupTriggeringPolicy />
<SizeBasedTriggeringPolicy size="10 MB"/>
<TimeBasedTriggeringPolicy />
</Policies>
</RollingFile>
<CasAppender name="casAudit">
<AppenderRef ref="auditlogfile" />
</CasAppender>
<CasAppender name="casFile">
<AppenderRef ref="file" />
</CasAppender>
<CasAppender name="casConsole">
<AppenderRef ref="console" />
</CasAppender>
<CasAppender name="casPerf">
<AppenderRef ref="perfFileAppender" />
</CasAppender>
</Appenders>
<Loggers>
<!-- If adding a Logger with level set higher than warn, make category as selective as possible -->
<!-- Loggers inherit appenders from Root Logger unless additivity is false -->
<AsyncLogger name="org.apereo" level="${sys:cas.log.level}" includeLocation="true"/>
<AsyncLogger name="org.apereo.services.persondir" level="${sys:cas.log.level}" includeLocation="true"/>
<AsyncLogger name="org.apereo.cas.web.flow" level="info" includeLocation="true"/>
<AsyncLogger name="org.apache" level="warn" />
<AsyncLogger name="org.apache.http" level="error" />
<AsyncLogger name="org.springframework" level="warn" />
<AsyncLogger name="org.springframework.cloud.server" level="warn" />
<AsyncLogger name="org.springframework.cloud.client" level="warn" />
<AsyncLogger name="org.springframework.cloud.bus" level="warn" />
<AsyncLogger name="org.springframework.aop" level="warn" />
<AsyncLogger name="org.springframework.boot" level="warn" />
<AsyncLogger name="org.springframework.boot.actuate.autoconfigure" level="warn" />
<AsyncLogger name="org.springframework.webflow" level="warn" />
<AsyncLogger name="org.springframework.session" level="warn" />
<AsyncLogger name="org.springframework.amqp" level="error" />
<AsyncLogger name="org.springframework.integration" level="warn" />
<AsyncLogger name="org.springframework.messaging" level="warn" />
<AsyncLogger name="org.springframework.web" level="warn" />
<AsyncLogger name="org.springframework.orm.jpa" level="warn" />
<AsyncLogger name="org.springframework.scheduling" level="warn" />
<AsyncLogger name="org.springframework.context.annotation" level="error" />
<AsyncLogger name="org.springframework.boot.devtools" level="error" />
<AsyncLogger name="org.springframework.web.socket" level="warn" />
<AsyncLogger name="org.thymeleaf" level="warn" />
<AsyncLogger name="org.pac4j" level="warn" />
<AsyncLogger name="org.opensaml" level="warn"/>
<AsyncLogger name="net.sf.ehcache" level="warn" />
<AsyncLogger name="com.couchbase" level="warn" includeLocation="true"/>
<AsyncLogger name="com.ryantenney.metrics" level="warn" />
<AsyncLogger name="net.jradius" level="warn" />
<AsyncLogger name="org.openid4java" level="warn" />
<AsyncLogger name="org.ldaptive" level="warn" />
<AsyncLogger name="com.hazelcast" level="warn" />
<AsyncLogger name="org.apereo.spring" level="warn" />
<!-- Log perf stats only to perfStats.log -->
<AsyncLogger name="perfStatsLogger" level="info" additivity="false" includeLocation="true">
<AppenderRef ref="casPerf"/>
</AsyncLogger>
<!-- Log audit to all root appenders, and also to audit log (additivity is not false) -->
<AsyncLogger name="org.apereo.inspektr.audit.support" level="info" includeLocation="true" >
<AppenderRef ref="casAudit"/>
</AsyncLogger>
<!-- All Loggers inherit appenders specified here, unless additivity="false" on the Logger -->
<AsyncRoot level="warn">
<AppenderRef ref="casFile"/>
<!--
For deployment to an application server running as service,
delete the casConsole appender below
-->
<AppenderRef ref="casConsole"/>
</AsyncRoot>
</Loggers>
</Configuration>
+366
View File
@@ -0,0 +1,366 @@
<?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>
<repositories>
<repository>
<id>sonatype-releases</id>
<url>http://oss.sonatype.org/content/repositories/releases/</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
<releases>
<enabled>true</enabled>
</releases>
</repository>
<repository>
<id>sonatype-snapshots</id>
<url>https://oss.sonatype.org/content/repositories/snapshots/</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
<releases>
<enabled>false</enabled>
</releases>
</repository>
<repository>
<id>shibboleth-releases</id>
<url>https://build.shibboleth.net/nexus/content/repositories/releases</url>
</repository>
</repositories>
<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>
</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,480 @@
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.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.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:http://sso.yunhaixingtong.com/front/index.html}")
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 {
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, "");
}
@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 = "/front/index.html?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)。
// 防御:默认落点若未配置、或误配成 CAS 原生登录页路径,则强制回退到前端首页,
// 避免出现“登录成功却被甩回 /cas/login”的死循环(曾因 application.yml 误配踩过)。
String target = defaultRedirectUrl;
if (target == null || target.trim().isEmpty() || target.contains("/cas/login")) {
target = "/front/index.html";
}
return buildCasAbsoluteUrl(request, target);
} catch (AuthenticationException | AbstractTicketException e) {
logger.error("用户 {} 建立 SSO 会话失败", username, e);
return null;
} catch (Exception e) {
logger.error("用户 {} 建立 SSO 会话异常", username, e);
return null;
}
}
/**
* 基于当前请求推断 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/**
+111
View File
@@ -0,0 +1,111 @@
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 参数)时的默认落点。必须是静态前端页,绝不能指向 CAS 原生登录页
# (/cas/login 或 /login)——否则登录成功会被甩回 CAS 登录页形成死循环。
# 该值会被打包进 WAR 的 classpath application.yml,且会覆盖 LoginController 中 @Value 的默认值,
# 所以这里必须显式写成前端页路径。业务系统带 service 参数时仍走各自回跳地址。
default-redirect-url: /front/index.html
# 是否强制每次登录都必须校验验证码。
# 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 协议的服务"
}
+2
View File
@@ -0,0 +1,2 @@
/index_.html
/.idea
+1
View File
@@ -0,0 +1 @@
a{text-decoration:none;color:#337ab7}a:hover{text-decoration:underline}*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;outline:none}html{font-size:14px}body{background-color:#eff2f6}
+1
View File
@@ -0,0 +1 @@
*::-webkit-scrollbar{width:10px;height:10px}*::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,0.05);border-radius:10px;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}*:hover::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,0.2);border-radius:10px;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}*::-webkit-scrollbar-thumb:hover{background-color:rgba(0,0,0,0.3);-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}*::-webkit-scrollbar-track{border-radius:10px;-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0);background-color:white}*::-webkit-scrollbar-track:hover{-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0.4);background-color:rgba(0,0,0,0.01)}.scroll2::-webkit-scrollbar{width:10px;height:10px}.scroll2::-webkit-scrollbar-track{border-radius:10px;-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0);background-color:white}.scroll2::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,0.05);border-radius:10px;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}.scroll2:hover::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,0.2);border-radius:10px;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}.scroll2::-webkit-scrollbar-track:hover{-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0.4)}.scroll2::-webkit-scrollbar-thumb:hover{-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0.4)}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
@media (max-width:768px){body{min-width:100%}html,body,#app{height:100%}.app-down,.banner-box{display:none}.footer-container{height:58px}header.header-wrapper{height:58px;padding:0 10px}.banner-wrapper{padding-top:48px;height:calc(100% - 105px)}.banner-box,.tabs,.app-list-wrapper,.wait-app-wrapper{display:none !important}.main-wrapper{width:100% !important;margin:0 auto !important}.login-wrapper{width:90% !important;margin:0 auto !important ;float:none !important}.login-header ul{padding:14px 0 !important}.login-header ul li{margin-right:10px !important}.login-form{margin:20px 15px !important}.login-form .form-item{margin-top:15px !important}.login-form .icon-box{width:40px !important;height:40px !important}.login-form p.input-box input{padding:4px 10px 4px 12px !important}.operate-box{margin:12px 0 !important}.operate-box a{font-size:12px !important}.btn-submit{height:40px !important;line-height:40px !important;font-size:14px !important;margin-bottom:10px !important}}
+1
View File
@@ -0,0 +1 @@
article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block;}audio,canvas,video{display:inline-block;}audio:not([controls]){display:none;height:0;}[hidden]{display:none;}html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;}body{margin:0;}a:focus{outline:thin dotted;}a:active,a:hover{outline:0;}h1{font-size:2em;margin:0.67em 0;}abbr[title]{border-bottom:1px dotted;}b,strong{font-weight:bold;}dfn{font-style:italic;}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0;}mark{background:#ff0;color:#000;}code,kbd,pre,samp{font-family:monospace,serif;font-size:1em;}pre{white-space:pre-wrap;}q{quotes:"\201C" "\201D" "\2018" "\2019";}small{font-size:80%;}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline;}sup{top:-0.5em;}sub{bottom:-0.25em;}img{border:0;}svg:not(:root){overflow:hidden;}figure{margin:0;}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em;}legend{border:0;padding:0;}button,input,select,textarea{font-family:inherit;font-size:100%;margin:0;}button,input{line-height:normal;}button,select{text-transform:none;}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer;}button[disabled],html input[disabled]{cursor:default;}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0;}input[type="search"]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box;}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none;}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;}textarea{overflow:auto;vertical-align:top;}table{border-collapse:collapse;border-spacing:0;}
+1
View File
@@ -0,0 +1 @@
.html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline;line-height:1.5}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:"";content:none}table{border-collapse:collapse;border-spacing:0}html,body{width:100%;height:100%;margin:0;scroll-behavior:smooth;-webkit-overflow-scrolling:touch}
+2
View File
@@ -0,0 +1,2 @@
/* http://meyerweb.com/eric/tools/css/reset/ v2.0 | 20110126 License: none (public domain)*/
.html, body, div, span, applet, object, iframe,h1, h2, h3, h4, h5, h6, p, blockquote, pre,a, abbr, acronym, address, big, cite, code,del, dfn, em, img, ins, kbd, q, s, samp,small, strike, strong, sub, sup, tt, var,b, u, i, center,dl, dt, dd, ol, ul, li,fieldset, form, label, legend,table, caption, tbody, tfoot, thead, tr, th, td,article, aside, canvas, details, embed,figure, figcaption, footer, header, hgroup,menu, nav, output, ruby, section, summary,time, mark, audio, video { margin: 0; padding: 0; border: 0; font-size: 100%; font: inherit; vertical-align: baseline;line-height: 1.5;}/* HTML5 display-role reset for older browsers */article, aside, details, figcaption, figure,footer, header, hgroup, menu, nav, section { display: block;}body { line-height: 1;}ol, ul { list-style: none;}blockquote, q { quotes: none;}blockquote:before, blockquote:after,q:before, q:after { content: ''; content: none;}table { border-collapse: collapse; border-spacing: 0;}html, body { width: 100%; height: 100%; overflow: auto; margin: 0; scroll-behavior: smooth; -webkit-overflow-scrolling: touch;}
+295
View File
@@ -0,0 +1,295 @@
/* @import "https://fonts.googleapis.com/css?family=Montserrat:400,700|Raleway:300,400"; */
/* colors */
/* tab setting */
/* breakpoints */
/* selectors relative to radio inputs */
html {
width: 100%;
height: 100%;
}
body {
/*background: #efefef;*/
color: #333;
/* font-family: "Raleway"; */
height: 100%;
}
body h1 {
text-align: center;
color: #428BFF;
font-weight: 300;
padding: 40px 0 20px 0;
margin: 0;
}
.tabs {
left: 50%;
-webkit-transform: translateX(-50%);
transform: translateX(-50%);
position: relative;
background: white;
padding: 50px;
padding-bottom: 80px;
margin-top: 50px;
width: 60%;
/*height: 250px;*/
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
border-radius: 5px;
min-width: 240px;
}
.tabs input[name="tab-control"] {
display: none;
}
.tabs .content section h2,
.tabs ul li label {
font-family: "Montserrat";
font-weight: bold;
font-size: 18px;
color: #428BFF;
}
.tabs ul {
list-style-type: none;
padding-left: 0;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
margin-bottom: 10px;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-box-align: end;
-webkit-align-items: flex-end;
-ms-flex-align: end;
align-items: flex-end;
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
}
.tabs ul li {
box-sizing: border-box;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
width: 25%;
padding: 0 10px;
text-align: center;
}
.tabs ul li label {
-webkit-transition: all 0.3s ease-in-out;
transition: all 0.3s ease-in-out;
color: #929daf;
padding: 5px auto;
overflow: hidden;
text-overflow: ellipsis;
display: block;
cursor: pointer;
-webkit-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
white-space: nowrap;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.tabs ul li label br {
display: none;
}
.tabs ul li label svg {
fill: #929daf;
height: 1.2em;
vertical-align: bottom;
margin-right: 0.2em;
-webkit-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
}
.tabs ul li label:hover, .tabs ul li label:focus, .tabs ul li label:active {
outline: 0;
color: #bec5cf;
}
.tabs ul li label:hover svg, .tabs ul li label:focus svg, .tabs ul li label:active svg {
fill: #bec5cf;
}
.tabs .slider {
position: relative;
width: 25%;
-webkit-transition: all 0.33s cubic-bezier(0.38, 0.8, 0.32, 1.07);
transition: all 0.33s cubic-bezier(0.38, 0.8, 0.32, 1.07);
}
.tabs .slider .indicator {
position: relative;
width: 50px;
max-width: 100%;
margin: 0 auto;
height: 4px;
background: #428BFF;
border-radius: 1px;
}
.tabs .content {
margin-top: 30px;
}
.tabs .content section {
display: none;
-webkit-animation-name: content;
animation-name: content;
-webkit-animation-direction: normal;
animation-direction: normal;
-webkit-animation-duration: 0.3s;
animation-duration: 0.3s;
-webkit-animation-timing-function: ease-in-out;
animation-timing-function: ease-in-out;
-webkit-animation-iteration-count: 1;
animation-iteration-count: 1;
line-height: 1.4;
}
.tabs .content section h2 {
color: #428BFF;
display: none;
}
.tabs .content section h2::after {
content: "";
position: relative;
display: block;
width: 30px;
height: 3px;
background: #428BFF;
margin-top: 5px;
left: 1px;
}
.tabs input[name="tab-control"]:nth-of-type(1):checked ~ ul > li:nth-child(1) > label {
cursor: default;
color: #428BFF;
}
.tabs input[name="tab-control"]:nth-of-type(1):checked ~ ul > li:nth-child(1) > label svg {
fill: #428BFF;
}
@media (max-width: 600px) {
.tabs input[name="tab-control"]:nth-of-type(1):checked ~ ul > li:nth-child(1) > label {
background: rgba(0, 0, 0, 0.08);
}
}
.tabs input[name="tab-control"]:nth-of-type(1):checked ~ .slider {
-webkit-transform: translateX(0%);
transform: translateX(0%);
}
.tabs input[name="tab-control"]:nth-of-type(1):checked ~ .content > section:nth-child(1) {
display: block;
}
.tabs input[name="tab-control"]:nth-of-type(2):checked ~ ul > li:nth-child(2) > label {
cursor: default;
color: #428BFF;
}
.tabs input[name="tab-control"]:nth-of-type(2):checked ~ ul > li:nth-child(2) > label svg {
fill: #428BFF;
}
@media (max-width: 600px) {
.tabs input[name="tab-control"]:nth-of-type(2):checked ~ ul > li:nth-child(2) > label {
background: rgba(0, 0, 0, 0.08);
}
}
.tabs input[name="tab-control"]:nth-of-type(2):checked ~ .slider {
-webkit-transform: translateX(100%);
transform: translateX(100%);
}
.tabs input[name="tab-control"]:nth-of-type(2):checked ~ .content > section:nth-child(2) {
display: block;
}
.tabs input[name="tab-control"]:nth-of-type(3):checked ~ ul > li:nth-child(3) > label {
cursor: default;
color: #428BFF;
}
.tabs input[name="tab-control"]:nth-of-type(3):checked ~ ul > li:nth-child(3) > label svg {
fill: #428BFF;
}
@media (max-width: 600px) {
.tabs input[name="tab-control"]:nth-of-type(3):checked ~ ul > li:nth-child(3) > label {
background: rgba(0, 0, 0, 0.08);
}
}
.tabs input[name="tab-control"]:nth-of-type(3):checked ~ .slider {
-webkit-transform: translateX(200%);
transform: translateX(200%);
}
.tabs input[name="tab-control"]:nth-of-type(3):checked ~ .content > section:nth-child(3) {
display: block;
}
.tabs input[name="tab-control"]:nth-of-type(4):checked ~ ul > li:nth-child(4) > label {
cursor: default;
color: #428BFF;
}
.tabs input[name="tab-control"]:nth-of-type(4):checked ~ ul > li:nth-child(4) > label svg {
fill: #428BFF;
}
@media (max-width: 600px) {
.tabs input[name="tab-control"]:nth-of-type(4):checked ~ ul > li:nth-child(4) > label {
background: rgba(0, 0, 0, 0.08);
}
}
.tabs input[name="tab-control"]:nth-of-type(4):checked ~ .slider {
-webkit-transform: translateX(300%);
transform: translateX(300%);
}
.tabs input[name="tab-control"]:nth-of-type(4):checked ~ .content > section:nth-child(4) {
display: block;
}
@-webkit-keyframes content {
from {
opacity: 0;
-webkit-transform: translateY(5%);
transform: translateY(5%);
}
to {
opacity: 1;
-webkit-transform: translateY(0%);
transform: translateY(0%);
}
}
@keyframes content {
from {
opacity: 0;
-webkit-transform: translateY(5%);
transform: translateY(5%);
}
to {
opacity: 1;
-webkit-transform: translateY(0%);
transform: translateY(0%);
}
}
@media (max-width: 1000px) {
.tabs ul li label {
white-space: initial;
}
.tabs ul li label br {
display: initial;
}
.tabs ul li label svg {
height: 1.5em;
}
}
@media (max-width: 600px) {
.tabs ul li label {
padding: 5px;
border-radius: 5px;
}
.tabs ul li label span {
display: none;
}
.tabs .slider {
display: none;
}
.tabs .content {
margin-top: 20px;
}
.tabs .content section h2 {
display: block;
}
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 880 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+281
View File
@@ -0,0 +1,281 @@
<!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>
<script src="./libs/jquery.min.js?v=2.1"></script>
<script src="./libs/vue.min.js?v=2.1"></script>
<script src="js/jsencrypt.min.js?v=2.1"></script>
<script src="js/conf.js?v=2.2"></script>
<script src="js/bschlogin.js?v=2.1"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f6f7;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.login-box {
background: #fff;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
width: 360px;
}
.login-box h2 {
text-align: center;
margin-bottom: 30px;
color: #333;
font-size: 22px;
}
.form-item {
margin-bottom: 16px;
}
.form-item input {
width: 100%;
padding: 12px 14px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.form-item input:focus {
border-color: #409eff;
}
.captcha-row {
display: flex;
gap: 10px;
align-items: center;
}
.captcha-row input {
flex: 1;
}
.captcha-row img {
height: 42px;
cursor: pointer;
border-radius: 4px;
border: 1px solid #ddd;
}
.btn-submit {
width: 100%;
padding: 12px;
background: #409eff;
color: #fff;
border: none;
border-radius: 4px;
font-size: 15px;
cursor: pointer;
transition: background 0.2s;
}
.btn-submit:hover {
background: #66b1ff;
}
.btn-submit:disabled {
background: #a0cfff;
cursor: not-allowed;
}
.warning {
color: #f56c6c;
font-size: 13px;
margin-top: 12px;
text-align: center;
min-height: 20px;
}
</style>
</head>
<body>
<div class="login-box">
<h2>用户登录</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" onclick="refreshCaptcha()" title="点击刷新" />
</div>
</div>
<button class="btn-submit" type="button" id="loginBtn" onclick="doLogin()">登录</button>
<div class="warning" id="warningLabel"></div>
</form>
</div>
<!-- 记住密码最佳努力:真实表单提交到隐藏 iframe(不跳转页面);主流程用 AJAX 拿 data.url 手动跳转,互不干扰 -->
<iframe name="pwdMgrFrame" style="display:none"></iframe>
<form id="pwdMgrForm" method="post" action="/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>
<script>
window.onerror = function (msg, src, line, col, err) {
var w = document.getElementById("warningLabel");
if (w) w.textContent = "页面脚本异常:" + msg;
return false;
};
var bschLogin;
try {
bschLogin = new BschLogin(ssoServerHost);
} catch (e) {
var w0 = document.getElementById("warningLabel");
if (w0) w0.textContent = "初始化失败:" + (e && e.message ? e.message : e);
}
window.onload = function () {
document.getElementById("service_hidden").value = bschLogin.getQueryVariable("service") || "";
bschLogin.getPublicKey();
bschLogin.isNeedCaptcha(function (isNeed) {
setCaptcha(isNeed);
});
var err = bschLogin.getQueryVariable("error");
if (err) setWarning(decodeURIComponent(err));
};
function setCaptcha(isVisible) {
var panel = document.getElementById("captchaPanel");
if (isVisible) {
document.getElementById("captchaImg").src = bschLogin.captchaSrc();
panel.style.display = "block";
panel.dataset.captcha = "true";
} else {
panel.style.display = "none";
panel.dataset.captcha = "false";
}
}
function refreshCaptcha() {
document.getElementById("captchaImg").src = bschLogin.captchaSrc();
}
function setWarning(msg) {
document.getElementById("warningLabel").textContent = msg || "";
}
function doLogin() {
var username = document.getElementById("user_input").value.trim();
var pwd = document.getElementById("password_input").value;
var captcha = document.getElementById("captcha_input").value;
var needCaptcha = document.getElementById("captchaPanel").dataset.captcha === "true";
if (!username) { setWarning("请输入用户名!"); return; }
if (!pwd) { setWarning("请输入密码!"); return; }
if (needCaptcha && !captcha) { setWarning("请输入验证码!"); return; }
if (!bschLogin.publicKey) { setWarning("公钥加载中,请稍候重试"); return; }
var btn = document.getElementById("loginBtn");
btn.disabled = true;
btn.textContent = "登录中...";
try {
var encrypt = new JSEncrypt();
encrypt.setPublicKey(bschLogin.publicKey);
var encUser = encrypt.encrypt(username);
var encPwd = encrypt.encrypt(pwd);
document.getElementById("loginname_input").value = encUser;
document.getElementById("pwd_input").value = encPwd;
var params = "loginname=" + encodeURIComponent(encUser)
+ "&pwd=" + encodeURIComponent(encPwd)
+ "&captcha=" + encodeURIComponent(captcha)
+ "&service=" + encodeURIComponent(document.getElementById("service_hidden").value);
var xhr = new XMLHttpRequest();
xhr.open("POST", "/api/login", true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
btn.disabled = false; btn.textContent = "登录";
if (xhr.status !== 200) { setWarning("登录请求失败"); return; }
try {
var resp = JSON.parse(xhr.responseText);
if (resp.code === 0 && resp.data && resp.data.url) {
setWarning("登录成功,正在跳转…");
// 最佳努力触发浏览器记住密码(真实表单提交到隐藏 iframe),再 JS 手动跳到 data.url
triggerPasswordSave(username, pwd, encUser, encPwd, captcha);
setTimeout(function () { window.location.href = resp.data.url; }, 400);
} else {
setWarning(resp.msg || "登录失败");
}
} catch (e) {
setWarning("登录响应解析失败");
}
};
xhr.send(params);
} catch (e) {
btn.disabled = false; btn.textContent = "登录";
setWarning("登录初始化失败,请重试");
}
}
function triggerPasswordSave(username, pwd, encUser, encPwd, captcha) {
try {
document.getElementById("pm_user").value = username;
document.getElementById("pm_pwd").value = pwd;
document.getElementById("pm_loginname").value = encUser;
document.getElementById("pm_pwd_enc").value = encPwd;
document.getElementById("pm_captcha").value = captcha;
document.getElementById("pm_service").value = document.getElementById("service_hidden").value;
document.getElementById("pwdMgrForm").submit();
} catch (e) {}
}
document.getElementById("user_input").addEventListener("keydown", function (e) {
if (e.key === "Enter") document.getElementById("password_input").focus();
});
document.getElementById("password_input").addEventListener("keydown", function (e) {
if (e.key === "Enter") {
if (document.getElementById("captchaPanel").style.display !== "none") {
document.getElementById("captcha_input").focus();
} else {
doLogin();
}
}
});
document.getElementById("captcha_input").addEventListener("keydown", function (e) {
if (e.key === "Enter") doLogin();
});
</script>
</body>
</html>
+746
View File
@@ -0,0 +1,746 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="Expires" content="0" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Cache-control" content="no-cache" />
<meta http-equiv="Cache" content="no-cache" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=0, minimum-scale=1.0, maximum-scale=1.0"
/>
<title>登录</title>
<link rel="stylesheet" href="./libs/swiper.min.css" />
<link rel="stylesheet" href="./css/index.css" />
<link rel="stylesheet" href="./css//mobile.css" />
<script src="./libs/swiper.min.js"></script>
<script src="./libs/jquery.min.js"></script>
<script src="./libs/vue.min.js"></script>
<script src="./libs/polyfill.object-fit.js"></script>
<script src="js/jquery.qrcode.js"></script>
<script src="js/qrcode.js"></script>
<script src="js/jsencrypt.min.js"></script>
<script src="js/conf.js"></script>
<script src="js/bschlogin.js"></script>
<script type="text/javascript">
var bschLogin = new BschLogin(loginPageTag, ssoServerHost, loginServerHost, baseServerHost);
function onNameKeyup(event) {
if (event.keyCode == 13) {
if (!$(event.target).val()) {
setWarning("请输入用户名!");
return;
}
var v = $("#password_input").val();
$("#password_input").val("").focus().val(v);
}
}
function onPwdKeyup(event) {
if (event.keyCode == 13) {
if (!$(event.target).val()) {
setWarning("请输入密码!");
return;
}
console.log($("#captchaPanel").data("captcha"));
if (!!$("#captchaPanel").data("captcha")) {
$("#captcha_input").val("").focus();
} else {
doLogin();
}
}
}
function onCaptchaKeyup(event) {
if (event.keyCode == 13) {
if (!$(event.target).val()) {
setWarning("请输入验证码!");
return;
}
doLogin();
}
}
function needCaptcha() {
bschLogin.isNeedCaptcha(function (isNeed) {
setCaptcha(isNeed);
});
}
function setCaptcha(isVisible) {
if (isVisible) {
$("#captchaImg").attr("src", bschLogin.captchaSrc());
$("#captchaPanel").css("display", "block").data("captcha", true);
// $(".login-wrapper").height(400);
} else {
$("#captchaPanel").css("display", "none").data("captcha", false);
}
}
function doLogin() {
var username = $("#user_input").val();
var pwd = $("#password_input").val();
var captcha = $("#captcha_input").val();
if (!username) {
setWarning("请输入用户名!");
return false;
}
if (!pwd) {
setWarning("请输入密码!");
return false;
}
if (!!$("#captchaPanel").data("captcha") && !captcha) {
setWarning("请输入验证码!");
return false;
}
bschLogin.login(
username,
pwd,
captcha,
function (url) {
window.location.href = url;
},
function (msg, needCaptcha) {
setWarning(msg);
setCaptcha(needCaptcha);
}
);
return false;
}
function getLoginQRCode() {
setQRCodeHint("请扫描二维码确认登录");
bschLogin.loginQrcode(
"#qrcode",
function (msg) {
setQRCodeHint("获取登录二维码失败");
},
function () {
setQRCodeHint("二维码已失效,点击刷新");
}
);
}
function setQRCodeHint(hint) {
$("#qrcodeHint").text(hint);
}
function setWarning(msg) {
if (typeof msg == "undefined" || msg == "") $("#warningPanel").css("display", "none");
else $("#warningPanel").css("display", "block");
$("#warningLabel").text(msg);
}
function onCountDown() {
var second = 60,
timer = null;
$("#js_vercode_btn")
.attr("disabled", true)
.text(second + "s");
timer = setInterval(function () {
second--;
if (second > 0) {
$("#js_vercode_btn").text(second + "s");
} else {
clearInterval(timer);
$("#js_vercode_btn").removeAttr("disabled").text("获取验证码");
}
}, 1000);
}
</script>
</head>
<body>
<div id="app" v-cloak>
<header class="header-wrapper">
<div class="main-wrapper fix">
<div class="logo-box align-middle fl" :style="{ height: logoHeight+'px' }">
<img v-if="settingMap.logoUrl" :src="settingMap.logoUrl" />
</div>
<div class="app-down align-middle fr">
<a href="javascript:void(0)" id="js_app_down">{{downTitle}}</a>
</div>
<div class="down-app-box">
<div class="tips-box"></div>
<img src="image/wem3.png" />
<h3>{{downInfo}}</h3>
</div>
</div>
</header>
<section class="banner-wrapper">
<div class="main-wrapper">
<div class="login-wrapper align-middle fr">
<div class="login-header">
<ul class="fix" id="js_login_tab">
<li class="fl active">密码登录</li>
<li class="fl">扫码登录</li>
<!-- <li class="fl">
<a
style="color: #656667; text-decoration: none"
rel="noopener noreferrer"
href="https://platform.xhedu.sh.cn/platform/app/login.php?url=https%3A%2F%2Fplatform-xhzx.xhedu.sh.cn&from=node3&ep_appid=100100289"
>
区统一登录</a
>
</li> -->
</ul>
<div class="header-slider active-1">
<div class="indicator"></div>
</div>
</div>
<div class="login-body js_pwd_login_box">
<form class="login-form" method="post" onsubmit="return doLogin()">
<div class="form-item">
<p class="input-box">
<input
type="text"
id="user_input"
placeholder="用户名/手机号/身份证号"
onkeyup="onNameKeyup(event)"
/>
</p>
</div>
<div class="form-item">
<p class="input-box">
<input
type="password"
id="password_input"
placeholder="请输入密码"
onkeyup="onPwdKeyup(event)"
/>
<span class="pwd-icon-box js_pwd_switch_btn">
<svg
id="js_open_eye"
style="display: none"
t="1619748143692"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="3873"
width="24"
height="24"
>
<path
d="M938.666667 501.333333c0-2.133333 0-2.133333-2.133334-4.266666-91.733333-142.933333-256-232.533333-426.666666-232.533334-177.066667 0-337.066667 89.6-428.8 243.2-2.133333 2.133333-2.133333 4.266667-2.133334 6.4v8.533334c0 2.133333 0 4.266667 2.133334 6.4C174.933333 680.533333 334.933333 770.133333 512 770.133333c66.133333 0 66.133333 0 145.066667-12.8 10.666667-2.133333 19.2-12.8 17.066666-23.466666-2.133333-10.666667-12.8-19.2-23.466666-17.066667-74.666667 12.8-74.666667 12.8-136.533334 12.8-157.866667 0-300.8-78.933333-386.133333-211.2 85.333333-132.266667 228.266667-211.2 386.133333-211.2 153.6 0 302.933333 81.066667 386.133334 209.066667-23.466667 38.4-32 46.933333-70.4 87.466666-8.533333 8.533333-8.533333 21.333333 0 29.866667 4.266667 4.266667 10.666667 6.4 14.933333 6.4 6.4 0 10.666667-2.133333 14.933333-6.4 44.8-44.8 51.2-53.333333 83.2-106.666667 4.266667-8.533333 2.133333-19.2-4.266666-25.6z"
fill="#B4B6B9"
p-id="3874"
></path>
<path
d="M377.6 514.133333c0 74.666667 61.866667 136.533333 136.533333 136.533334s136.533333-61.866667 136.533334-136.533334-61.866667-136.533333-136.533334-136.533333-136.533333 61.866667-136.533333 136.533333z m232.533333 0c0 51.2-42.666667 93.866667-93.866666 93.866667-51.2 0-93.866667-42.666667-93.866667-93.866667s42.666667-93.866667 93.866667-93.866666c51.2 0 93.866667 42.666667 93.866666 93.866666z"
fill="#B4B6B9"
p-id="3875"
></path>
<path
d="M757.333333 686.933333m-32 0a32 32 0 1 0 64 0 32 32 0 1 0-64 0Z"
fill="#B4B6B9"
p-id="3876"
></path>
</svg>
<svg
id="js_close_eye"
t="1619764005511"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="2031"
width="18"
height="18"
>
<path
d="M938.666667 317.866667m-29.866667 0a29.866667 29.866667 0 1 0 59.733333 0 29.866667 29.866667 0 1 0-59.733333 0Z"
fill="#B4B6B9"
p-id="2032"
></path>
<path
d="M876.8 454.4c6.4-6.4 12.8-14.933333 19.2-21.333333 8.533333-8.533333 6.4-23.466667-2.133333-29.866667-8.533333-8.533333-23.466667-6.4-29.866667 2.133333-85.333333 100.266667-209.066667 157.866667-341.333333 157.866667-166.4 0-317.866667-91.733333-394.666667-238.933333-6.4-10.666667-19.2-14.933333-27.733333-8.533334-10.666667 6.4-14.933333 19.2-8.533334 27.733334 14.933333 27.733333 32 53.333333 49.066667 76.8L64 499.2c-8.533333 8.533333-8.533333 23.466667 0 32 4.266667 4.266667 8.533333 6.4 14.933333 6.4 6.4 0 12.8-2.133333 17.066667-8.533333l72.533333-76.8c36.266667 40.533333 81.066667 72.533333 128 98.133333l-55.466666 102.4c-6.4 10.666667-2.133333 25.6 8.533333 32 2.133333 2.133333 6.4 2.133333 8.533333 2.133333 8.533333 0 17.066667-4.266667 21.333334-12.8l55.466666-104.533333c51.2 21.333333 106.666667 34.133333 162.133334 36.266667v106.666666c0 12.8 10.666667 23.466667 21.333333 23.466667 12.8 0 21.333333-10.666667 21.333333-23.466667v-106.666666c53.333333-2.133333 104.533333-12.8 151.466667-29.866667l55.466667 102.4c4.266667 8.533333 12.8 12.8 21.333333 12.8 2.133333 0 6.4 0 8.533333-2.133333 10.666667-6.4 12.8-19.2 8.533334-32l-53.333334-98.133334c40.533333-19.2 78.933333-44.8 113.066667-74.666666l74.666667 78.933333c4.266667 4.266667 10.666667 8.533333 17.066666 8.533333 4.266667 0 10.666667-2.133333 14.933334-6.4 8.533333-8.533333 8.533333-23.466667 0-32l-74.666667-78.933333z"
fill="#B4B6B9"
p-id="2033"
></path>
</svg>
</span>
</p>
</div>
<div class="hide" id="captchaPanel" data-captcha="false">
<div class="form-item" style="width: 65%; display: inline-block">
<p class="input-box">
<input
type="text"
id="captcha_input"
placeholder="输入右侧验证码"
onkeyup="onCaptchaKeyup(event)"
/>
</p>
</div>
<img
id="captchaImg"
style="width: 70px; height: 30px; float: right; margin-top: 20px"
onClick="setCaptcha(true)"
/>
</div>
<div class="operate-box fix">
<!-- <label>
<input type="checkbox" class="login-checkbox" />记住密码
</label> -->
<a href="javascript:;" id="js_forget_pw">忘记密码?</a>
</div>
<button class="btn-submit" type="submit">登录</button>
<div id="warningPanel" class="warning-box hide">
<span id="warningLabel"></span>
</div>
</form>
</div>
<div class="login-body js_qr_login_box hide">
<div class="qrcode-box">
<div id="qrcode" style="margin-bottom: 10px" onclick="getLoginQRCode()"></div>
<p id="qrcodeHint">扫码登录</p>
</div>
</div>
</div>
<div class="forgetpw-wrapper align-middle fr hide">
<div class="login-header">
<ul class="fix" id="js_login_tab">
<li class="fl">忘记密码</li>
</ul>
</div>
<div class="login-body js_forgetpw_box">
<p class="tips-info-box">密码规则:长度8-18位且包括数字、字母和符号!</p>
<div class="login-form">
<div class="form-item">
<p class="input-box">
<input
type="text"
maxlength="11"
id="tel"
name="tel"
v-model="reset.phone"
placeholder="请输入手机号"
/>
</p>
</div>
<div class="form-item">
<p class="input-box">
<input
type="password"
maxlength="18"
v-model="reset.password"
placeholder="请输入新密码"
/>
</p>
</div>
<div class="form-item">
<p class="input-box">
<input
type="password"
maxlength="18"
v-model="reset.passworded"
placeholder="请再次确认新密码"
/>
</p>
</div>
<div class="vercode-wrap fix">
<div class="form-item fl">
<p class="input-box">
<input
v-model="reset.verifyCode"
type="text"
maxlength="6"
placeholder="输入验证码"
/>
</p>
</div>
<button id="js_vercode_btn" @click="sendVerifyCode" class="btn-vercode fr">
获取验证码
</button>
</div>
<button class="btn-submit" style="margin-top: 20px" @click="doForgetPw" type="submit">
下一步
</button>
<div v-if="!!warningMsg" class="warning-box">
<span>{{warningMsg}}</span>
</div>
</div>
</div>
<div class="forgetpw-stats-box js_forgetpw_stats_box hide">
<img class="tc" src="./image/success-stats.png" alt="" />
<h4>密码修改成功</h4>
<p>恭喜您密码修改成功,请返回首页重新登录</p>
<a href="./index.html">
<button class="btn-submit">返回登录</button>
</a>
</div>
</div>
</div>
<div class="banner-box">
<img v-if="settingMap.banner.length==1" :src="settingMap.banner[0]" class="js_fit" />
<div v-else-if="settingMap.banner.length>1" class="swiper-container">
<div class="swiper-wrapper">
<div class="swiper-slide" v-for="item in settingMap.banner">
<img :src="item" class="js_fit" />
</div>
</div>
<!-- 如果需要分页器 -->
<div class="swiper-pagination"></div>
</div>
</div>
</section>
<div id="js_tabs" class="main-wrapper tabs" v-if="settingMap.isShowApp">
<ul class="tabs-nav fix">
<template v-for="(item,index) in tabs">
<li :class="[index==0?'active':'','apptype-li']">{{item}}</li>
<li v-if="index!=tabs.length-1" class="spacer-li">
<span>/</span>
</li>
</template>
</ul>
<div class="tabs-slider" v-if="tabs.length>0">
<div class="indicator"></div>
</div>
</div>
<section id="app_panel" class="app-list-wrapper" v-if="settingMap.isShowApp">
<ul class="app-ul fix">
<li class="" v-for="app in appList">
<a href="#">
<div class="icon-box">
<img v-bind:src="app.icon" class="" data-load="1" />
</div>
</a>
<h4 class="info">{{app.name}}</h4>
</li>
</ul>
</section>
<footer :class="{'footer-container':true,'theme-light':!settingMap.isShowApp}">
<!-- <p class="align-middle">
©2018bestsch.com inc All Rights reserved 备案号:沪ICP备13000443号-1
</p> -->
<p class="align-middle">
{{beianText}}
<a target="_blank" href="https://beian.miit.gov.cn/">{{beianLink}}</a>
</p>
</footer>
</div>
<script>
needCaptcha();
getLoginQRCode();
</script>
<script>
var vm = new Vue({
el: "#app",
data: {
appTypeList: [],
appList: [],
tabs: [],
appName: "",
downTitle: "",
downInfo: "",
settingMap: {
logoUrl: "",
isShowApp: true,
banner: [],
},
beianText: window.beianText,
beianLink: window.beianLink,
logoHeight: window.logoHeight || 35,
warningMsg: null,
reset: {
phone: null,
verifyCode: null,
password: null,
passworded: null,
},
windowWidth: window.innerWidth,
},
computed: {
isMobile() {
return this.windowWidth < 768;
},
},
mounted: function () {
var _this = this;
_this.appName = window.appName;
_this.downTitle = window.downTitle;
_this.downInfo = window.downInfo;
_this.getSetting();
},
methods: {
getApp: function () {
var _this = this;
var url = loginServerHost + "api/v2/getSchoolApp";
$.get(
url,
{
schId: schId,
},
function (resp) {
if (resp.code == 0) {
_this.appTypeList = resp.data;
_this.showAppType();
} else console.log("获取应用列表失败:" + resp.msg);
},
"json"
).error(function () {
console.log("获取应用列表失败");
});
},
getSetting: function () {
var _this = this;
var setSettingMap = function (data) {
_this.settingMap = data;
var bannerHeight = data.isShowApp ? 480 : $("body").height() - 186;
if (data.isShowApp) _this.getApp();
_this.$nextTick(function () {
objectFitImages("img.js_fit"); //IE图片拉伸
if (!_this.isMobile) {
$(".banner-wrapper,.swiper-container,.js_fit").height(bannerHeight);
if (data.banner.length > 1) {
var mySwiper = new Swiper(".swiper-container", {
pagination: ".swiper-pagination",
loop: true,
paginationClickable: true,
autoplay: 2800,
speed: 500,
});
}
} else {
$(".banner-wrapper .main-wrapper").css({
"background-image": "url(" + data.banner[0] + ")",
"background-size": "cover",
});
}
});
};
// setSettingMap(window.defaultSetting);
var url = loginServerHost + "api/v2/getSchoolLoginPageConfig";
$.get(
url,
{
schId: schId,
},
function (resp) {
if (resp.code == 0) {
var rData = resp.data;
rData.banner = rData.bannerUrls;
setSettingMap(resp.data);
} else setSettingMap(window.defaultSetting);
},
"json"
).error(function () {
setSettingMap(window.defaultSetting);
console.log("获取设置失败");
});
},
showAppType: function () {
var _this = this;
_this.tabs.push("全部应用");
var len = _this.appTypeList.length;
for (var i = 0; i < len; i++) {
var appType = _this.appTypeList[i];
_this.tabs.push(appType.name);
}
_this.switchApp(0);
},
switchApp: function (index) {
var _this = this;
if (index == 0) {
var appList = [];
var len = _this.appTypeList.length;
for (var i = 0; i < len; i++) {
var appType = _this.appTypeList[i];
var appCount = appType.app.length;
for (var j = 0; j < appCount; j++) appList.push(appType.app[j]);
}
_this.appList = appList;
} else {
var appType = _this.appTypeList[index - 1];
_this.appList = appType.app;
}
},
sendVerifyCode: function () {
var _this = this;
var telreg = /^[1][3,4,5,6,7,8,9][0-9]{9}$/;
if (!telreg.test(_this.reset.phone)) {
this.warningMsg = "请输入正确的手机号!";
return false;
}
$("#js_vercode_btn").attr("disabled", true);
bschLogin.requestSmsCode(
_this.reset.phone,
function () {
onCountDown();
},
function (msg) {
alert("验证码获取失败!");
// alert("验证码获取失败:" + msg);
$("#js_vercode_btn").removeAttr("disabled").text("获取验证码");
}
);
},
doForgetPw: function () {
var _this = this;
if (!_this.reset.phone) {
this.warningMsg = "请输入手机号!";
return false;
}
var telreg = /^[1][3,4,5,6,7,8,9][0-9]{9}$/;
if (!telreg.test(_this.reset.phone)) {
this.warningMsg = "请输入正确的手机号!";
return false;
}
if (!_this.reset.password) {
this.warningMsg = "请输入新密码!";
return false;
}
if (!_this.reset.passworded) {
this.warningMsg = "请再次确认新密码!";
return false;
}
if (_this.reset.password !== _this.reset.passworded) {
this.warningMsg = "两次输入的密码不一致!";
return false;
}
var pwreg = /^(?=.*[a-z])(?=.*[0-9])(?=.*).{8,}$/;
if (!pwreg.test(_this.reset.password)) {
this.warningMsg = "密码长度应大于8位且应包括数字、字母和符号!";
return false;
}
if (!_this.reset.verifyCode) {
this.warningMsg = "请输入收到的验证码!";
return false;
}
var item = JSON.parse(JSON.stringify(_this.reset));
delete item.passworded;
bschLogin.updatePwd(
item.phone,
item.verifyCode,
item.password,
function () {
$(".js_forgetpw_box").addClass("hide");
$(".js_forgetpw_stats_box").removeClass("hide");
},
function (error) {
// alert(error);
_this.warningMsg = error;
}
);
// $.post(
// "url",
// item,
// function (resp) {
// $(".js_forgetpw_box").addClass("hide");
// $(".js_forgetpw_stats_box").removeClass("hide");
// },
// function (error) {
// console.log(error);
// alert("验证码获取失败!");
// $("#js_vercode_btn").removeAttr("disabled").text("获取验证码");
// }
// );
},
},
});
$(function () {
$("#js_login_tab").on("click", "li", function (a, b) {
$("#js_login_tab .active").removeClass("active");
$(this).addClass("active");
var index = parseInt($(this).index()) + 1;
if (index == 3) return;
$(".login-wrapper .login-body").hide();
if (index == 1) {
$(".js_pwd_login_box").fadeToggle();
} else if (index == 2) {
$(".js_qr_login_box").fadeToggle();
}
var slider = $("#js_login_tab + .header-slider");
slider.attr({
class: "",
});
slider.addClass("header-slider active-" + index);
});
$("#js_tabs").on("click", "li.apptype-li", function (ele) {
var index = $(this).index(),
sliderWidth = 0,
currWidth = 0;
$("#js_tabs .active").removeClass("active");
$("#js_tabs .tabs-nav")
.children()
.each(function (i, dom) {
if (i < index) {
sliderWidth += $(dom).width();
}
if (i == index) {
$(dom).addClass("active");
currWidth = $(dom).width();
}
});
$("#js_tabs .tabs-slider").css({
transform: "translateX(" + sliderWidth + "px)",
width: currWidth,
});
vm.switchApp(index / 2);
});
$(".js_pwd_switch_btn").click(function () {
var pwd = $("#password_input");
if (pwd.attr("type") === "password") {
$("#password_input").attr("type", "text");
} else {
$("#password_input").attr("type", "password");
}
$("#js_close_eye,#js_open_eye").toggle();
});
$("#js_app_down").click(function (e) {
$(".down-app-box").fadeIn(300);
e.stopPropagation();
document.onclick = function () {
$(".down-app-box").fadeOut(300);
document.onclick = null;
};
});
//setWarning
$("#user_input,#password_input").change(function () {
setWarning("");
});
$(".js_forgetpw_box input").change(function () {
vm.warningMsg = null;
});
$("#js_forget_pw").click(function () {
$(".login-wrapper").addClass("hide");
$(".forgetpw-wrapper").removeClass("hide");
});
});
</script>
</body>
</html>
+164
View File
@@ -0,0 +1,164 @@
function BschLogin(ssoServerHost) {
this.isDebug = false;
this.ssoServerHost = ssoServerHost;
this.captchaUrl = ssoServerHost + "api/captcha";
this.loginUrl = ssoServerHost + "api/login";
this.publicKeyUrl = ssoServerHost + "api/public-key";
this.needCaptchaUrl = ssoServerHost + "api/need-captcha";
this.publicKey = "";
// this.setCookie("loginPageTag", loginPageTag, 30);
}
BschLogin.prototype = {
getPublicKey: function () {
var _this = this;
if (_this.isDebug) {
_this.publicKey =
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqytbHgQWZQmkwmp6WncVaUmUNgExYDB0Sp8h1qfyKfufbX2MEM6UzSPQHIwurFqkzcHljdrGDCXWZ2Xv8c8C27+MOPLf4VyYQ7invqtvmEwS6c3km4QqQVdrh0DJjVYhgO8reE8LhlDFc9Vh9FwySb8pbC1EM0s2lN6ig7/9NR8+CqxW61ku+7VYnLzcRgpc0cF3K/mQwAjVf1pKjGtPG1mpLNcHpT2yp5u950GfFtdqYgAte7k4CDlwMDKfpf+2Pg5kZAzogO5DTq/QolOWqjZfgT2wBNpYVT+3glbhhRbc9T3/SqFeT9KlXcamBXLvKI6WuKxlAEeKTiosUgMKCQIDAQAB";
return;
}
$.get(
_this.publicKeyUrl,
function (resp) {
_this.publicKey = resp.data.publicKey;
},
"json",
);
},
//是否需要验证校验码
isNeedCaptcha: function (callback) {
if (this.isDebug) return callback(false);
$.get(
this.needCaptchaUrl,
function (resp) {
if (resp.code == 0) {
var needCaptcha = resp.data.needCaptcha;
callback(needCaptcha);
} else {
callback(true);
}
},
"json",
);
},
//登录
login: function (username, pwd, captcha, onSuccess, onFailed) {
var service = this.getQueryVariable("service");
var encrypt = new JSEncrypt();
encrypt.setPublicKey(this.publicKey);
username = encrypt.encrypt(username);
pwd = encrypt.encrypt(pwd);
var params = {
loginname: username,
pwd: pwd,
captcha: captcha,
};
if (typeof service === "string") params.service = service;
console.log(JSON.stringify(params));
$.ajax({
url: this.loginUrl,
type: "POST",
data: params,
dataType: "json",
success: function (resp, textStatus, jqXHR) {
if (resp && typeof resp.code !== "undefined") {
if (resp.code == 0) {
var url = (resp.data && resp.data.url) || "";
// 兜底:后端未返回有效地址或返回了 CAS 原生登录页路径时,跳回同源根
if (!url || url.indexOf("/cas/login") === 0) {
url = "/";
}
console.log("login success, redirect =>", url);
onSuccess(url);
} else {
var needCaptcha = resp.data ? resp.data.needCaptcha : false;
onFailed(resp.msg, needCaptcha);
}
return;
}
onSuccess(jqXHR.responseURL || "");
},
error: function (jqXHR) {
var resp = jqXHR.responseJSON;
if (resp && typeof resp.code !== "undefined") {
var needCaptcha = resp.data ? resp.data.needCaptcha : false;
onFailed(resp.msg, needCaptcha);
return;
}
onFailed("登录请求失败", false);
},
});
},
//请求短信验证码
requestSmsCode: function (phoneCode, onSuccess, onFailed) {
var params = {
phone: phoneCode,
};
$.get(
this.smsCodeUrl,
params,
function (resp) {
if (resp.code == 0) {
onSuccess();
} else {
onFailed(resp.msg);
}
},
"json",
);
},
//按短信验证码,修改密码
updatePwd: function (phoneCode, smsCode, newPwd, onSuccess, onFailed) {
var params = {
phone: phoneCode,
smsCode: smsCode,
newPwd: newPwd,
};
$.post(
this.updatePwdUrl,
params,
function (resp) {
if (resp.code == 0) {
onSuccess();
} else {
onFailed(resp.msg);
}
},
"json",
);
},
captchaSrc: function () {
return this.captchaUrl + "?k=" + new Date().getTime();
},
getQueryVariable: function (paramName) {
var query = window.location.search.substring(1);
var params = query.split("&");
for (var i = 0; i < params.length; i++) {
var param = params[i];
var pos = param.indexOf("=");
if (pos == -1) continue;
var key = param.slice(0, pos);
var value = param.slice(pos + 1, param.length);
if (key == paramName) return value;
}
return "";
},
setCookie: function (c_name, value, expiredays) {
var exdate = new Date();
exdate.setDate(exdate.getDate() + expiredays);
document.cookie =
c_name + "=" + escape(value) + (expiredays == null ? "" : ";expires=" + exdate.toGMTString()) + ";path=/"; //+";domain=";
},
};
+7
View File
@@ -0,0 +1,7 @@
// CAS context-path 为根 "/",前端由 CAS 同源托管在 /front/,接口在 /api/*。
// 用同源相对路径,避免写死 host/协议,且天然规避跨域(LoginController 未开 CORS)。
window.ssoServerHost = "/cas/";
window.beianText = "©2021 bestsch.com inc All Rights reserved";
window.beianLink = "【沪ICP备13000443号-1】";
window.logoHeight = 35;
+9210
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
(function( $ )
{
var element;
$.fn.qrcode = function(options)
{
// if options is string,
if( typeof options === 'string' ){
options = { text: options };
}
// set default values
// typeNumber < 1 for automatic calculation
options = $.extend( {}, {
render : "canvas",
width : 256,
height : 256,
typeNumber : -1,
correctLevel : QRErrorCorrectLevel.H,
background : "#ffffff",
foreground : "#000000"
}, options);
var createCanvas = function(){
// create the qrcode itself
var qrcode = new QRCode(options.typeNumber, options.correctLevel);
qrcode.addData(options.text);
qrcode.make();
// create canvas element
var canvas = document.createElement('canvas');
canvas.width = options.width;
canvas.height = options.height;
var ctx = canvas.getContext('2d');
// compute tileW/tileH based on options.width/options.height
var tileW = options.width / qrcode.getModuleCount();
var tileH = options.height / qrcode.getModuleCount();
// draw in the canvas
for( var row = 0; row < qrcode.getModuleCount(); row++ ){
for( var col = 0; col < qrcode.getModuleCount(); col++ ){
ctx.fillStyle = qrcode.isDark(row, col) ? options.foreground : options.background;
var w = (Math.ceil((col+1)*tileW) - Math.floor(col*tileW));
var h = (Math.ceil((row+1)*tileW) - Math.floor(row*tileW));
ctx.fillRect(Math.round(col*tileW),Math.round(row*tileH), w, h);
}
}
// return just built canvas
return canvas;
}
// from Jon-Carlos Rivera (https://github.com/imbcmdth)
var createTable = function(){
// create the qrcode itself
var qrcode = new QRCode(options.typeNumber, options.correctLevel);
qrcode.addData(options.text);
qrcode.make();
// create table element
var $table = $('<table></table>')
.css("width", options.width+"px")
.css("height", options.height+"px")
.css("border", "0px")
.css("border-collapse", "collapse")
.css('background-color', options.background);
// compute tileS percentage
var tileW = options.width / qrcode.getModuleCount();
var tileH = options.height / qrcode.getModuleCount();
// draw in the table
for(var row = 0; row < qrcode.getModuleCount(); row++ ){
var $row = $('<tr></tr>').css('height', tileH+"px").appendTo($table);
for(var col = 0; col < qrcode.getModuleCount(); col++ ){
$('<td></td>')
.css('width', tileW+"px")
.css('background-color', qrcode.isDark(row, col) ? options.foreground : options.background)
.appendTo($row);
}
}
// return just built canvas
return $table;
}
return this.each(function()
{
if (typeof(element) != "undefined")
element.remove();
element = options.render == "canvas" ? createCanvas() : createTable();
$(element).appendTo(this);
});
};
})( jQuery );
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
// 一些全局设置样式
a{
text-decoration: none;
color: #337ab7;
&:hover{
text-decoration:underline;
}
}
*{
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
outline: none;
}
html{font-size: 14px;}
body{background-color: #eff2f6;}
+84
View File
@@ -0,0 +1,84 @@
/*滚动条样式 */
/*第一种样式,所有的滚动条,都默认这个样子*/
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
/*正常情况下滑块的样式*/
*::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, .05);
border-radius: 10px;
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
}
/*鼠标悬浮在该类指向的控件上时滑块的样式*/
*:hover::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, .2);
border-radius: 10px;
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
}
/*鼠标悬浮在滑块上时滑块的样式*/
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, .3);
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
}
/*正常时候的主干部分*/
*::-webkit-scrollbar-track {
border-radius: 10px;
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
background-color: white;
}
/*鼠标悬浮在滚动条上的主干部分*/
*::-webkit-scrollbar-track:hover {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, .4);
background-color: rgba(0, 0, 0, .01);
}
/*第一种样式结束*/
/*第二种样式*/
.scroll2::-webkit-scrollbar {
width: 10px;
height: 10px;
}
/*正常时候的主干部分*/
.scroll2::-webkit-scrollbar-track {
border-radius: 10px;
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
background-color: white;
}
/*正常情况下滑块的样式*/
.scroll2::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, .05);
border-radius: 10px;
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
}
/*鼠标悬浮在该类指向的控件上时滑块的样式*/
.scroll2:hover::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, .2);
border-radius: 10px;
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
}
/*鼠标悬浮在滚动条上的主干部分*/
.scroll2::-webkit-scrollbar-track:hover {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, .4);
}
/*鼠标悬浮在滑块上时滑块的样式*/
.scroll2::-webkit-scrollbar-thumb:hover {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, .4);
}
/*第二种样式结束*/
+663
View File
@@ -0,0 +1,663 @@
@import "reset.less";
// @import "normalize.less";
@import "bscroll.less";
.align-middle {
position: relative;
top: 50%;
transform: translateY(-50%);
}
.align-middle-wrap {
display: flex;
align-items: center;
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
* {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", "微软雅黑", Arial, sans-serif;
// font-family: "Helvetica Neue", "Luxi Sans", "DejaVu Sans", "Hiragino Sans GB", "Microsoft YaHei" , "微软雅黑";
}
html {
font-size: 14px;
color: #272829;
}
body {
background-color: #f8f9fa;
min-width: 1002px;
line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
[v-cloak] {
display: none;
}
// 浮动与清除浮动
.fl {
float: left !important;
}
.fr {
float: right !important;
}
.fix {
*zoom: 1;
}
.fix:after {
display: table;
content: "";
clear: both;
}
.hide {
display: none;
}
.main-wrapper {
width: 1050px;
margin: 0 auto;
position: relative;
}
.base-slider {
width: 48px;
position: absolute;
left: 7px;
bottom: -1px;
z-index: 1;
transition: transform 0.3s ease-in-out;
transform-origin: 0 0;
.indicator {
width: 40px;
height: 2px;
background: #272829;
margin: 0 auto;
}
}
header.header-wrapper {
height: 88px;
background: #fff;
position: fixed;
top: 0;
width: 100%;
z-index: 10;
box-shadow: 0px 10px 20px 0px rgba(239, 239, 239, 0.09);
.logo-box {
height: 35px;
max-height: 60px;
overflow: hidden;
img {
height: 100%;
}
}
.schname-box {
line-height: 32px;
height: 32px;
margin-left: 8px;
font-size: 20px;
font-weight: bold;
font-weight: 700;
font-family: SimSun;
font-family: SourceHanSerifCN-Bold, SourceHanSerifCN;
}
.app-down {
margin-right: 5px;
font-size: 15px;
height: 35px;
line-height: 35px;
font-weight: 400;
a {
color: #272829;
text-decoration: none;
}
}
.down-app-box {
width: 200px;
height: 222px;
box-sizing: border-box;
position: absolute;
top: 65px;
right: 0px;
background: #fff;
border: 1px solid #e8e8e8;
text-align: center;
border-radius: 2px;
display: none;
.tips-box {
position: absolute;
top: 0px;
right: 40px;
&::after {
display: block;
content: "";
position: absolute;
left: 0px;
top: -9px;
width: 0;
height: 0;
border-right: 8px solid transparent;
border-bottom: 8px solid #e8e8e8;
border-left: 8px solid transparent;
z-index: 2;
}
&::before {
display: block;
content: "";
position: absolute;
left: 0px;
top: -8px;
width: 0;
height: 0;
border-right: 8px solid transparent;
border-bottom: 8px solid #fff;
border-left: 8px solid transparent;
z-index: 4;
}
}
h3 {
height: 20px;
font-size: 15px;
font-weight: 400;
color: #272829;
line-height: 20px;
margin-top: 5px;
}
img {
margin-top: 26px;
width: 140px;
height: 140px;
}
}
.main-wrapper {
height: 100%;
}
}
.banner-wrapper {
width: 100%;
height: 480px;
min-height: 480px;
// background: url("../image/banner-bg.png") no-repeat center center/ cover;
position: relative;
padding-top: 88px;
.main-wrapper {
height: 100%;
}
.banner-box {
position: absolute;
width: 100%;
left: 0;
top: 88px;
bottom: 0;
z-index: 1;
transform: translate3d(0, 0, 0);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
font-family: "object-fit: cover;";
}
}
.warning-box {
span {
color: #ff0000;
}
}
.qrcode-box {
margin-top: 35px;
margin-bottom: 35px;
cursor: pointer;
}
.login-wrapper {
width: 320px;
background: #ffffff;
border-radius: 6px;
box-sizing: border-box;
position: relative;
z-index: 3;
}
.login-header {
border-bottom: 1px solid #f6f6f6;
padding-top: 10px;
position: relative;
margin: 0 30px;
// margin-bottom: 28px;
ul {
padding: 14px 0;
li {
user-select: none;
cursor: pointer;
height: 20px;
line-height: 20px;
margin-right: 14px;
color: #656667;
font-weight: 550;
}
li.active {
color: #272829;
}
}
.header-slider {
.base-slider;
left: 0;
.indicator {
float: left;
}
&.active-1 {
transform: translateX(0);
}
&.active-2 {
transform: translateX(70px);
}
&.active-3 {
transform: translateX(140px);
}
}
}
.login-body {
position: relative;
}
.login-footer {
height: 22px;
margin: 15px 40px;
}
.login-form {
margin:40px 30px 20px;
.form-item {
margin-top: 17px;
width: 100%;
height: 40px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #f1f1f1;
}
.icon-box {
background: #fff;
width: 48px;
height: 40px;
float: left;
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid #f1f1f1;
box-sizing: border-box;
}
p.input-box {
height: 40px;
box-sizing: border-box;
input {
width: 100%;
height: 40px;
padding: 4px 10px 4px 16px;
box-sizing: border-box;
border: none;
outline: none;
background-color: #fafafa;
}
#password_input {
width: calc(100% - 48px);
&::-ms-clear {
display: none;
}
&::-ms-reveal {
display: none;
}
}
.pwd-icon-box {
.icon-box;
float: right;
background-color: #fafafa;
cursor: pointer;
user-select: none;
}
}
.operate-box{
margin: 12px 0;
.fix;
label{
color: #b1b1b1;
font-size: 12px;
user-select: none;
}
.login-checkbox {
outline: none;
cursor: pointer;
position: relative;
top: -1px;
vertical-align: middle;
}
a{
font-size: 12px;
float: right;
color: #6d8cf0;
}
}
}
.btn-submit {
display: inline-block;
line-height: 42px;
height: 40px;
width: 100%;
font-size: 14px;
color: #fff;
cursor: pointer;
text-decoration: none;
outline: none;
padding: 0px 20px;
position: relative;
border-radius: 4px;
overflow: hidden;
background: #6b8cf4;
border: none;
margin-bottom: 10px;
&::before {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0px;
background: rgba(0, 0, 0, 0.1);
box-sizing: content-box;
}
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(255, 255, 255, 0.2);
}
&:hover::before {
content: "";
}
&:active::after {
content: "";
}
}
.btn-vercode{
overflow: hidden;
background: #6b8cf4;
color: #fff;
cursor: pointer;
text-decoration: none;
border: none;
outline: none;
width:100px;
height: 36px;
line-height: 36px;
border-radius: 36px;
margin:0;
margin-top:17px;
&[disabled]{
background: #b6c5f7;
}
}
.vercode-wrap{
.form-item{
width:calc(100% - 116px);
}
}
// .login-form,
// .login-form .btn-submit {
// margin-top: 40px;
// }
.qrcode-box {
text-align: center;
img {
margin: 42px auto 25px;
width: 130px;
height: 130px;
}
p {
font-size: 13px;
color: #656667;
margin: 0 40px;
}
}
.forgetpw-wrapper {
width: 320px;
background: #ffffff;
border-radius: 6px;
box-sizing: border-box;
position: relative;
z-index: 3;
.login-form{
margin-top:10px;
}
}
.forgetpw-stats-box{
text-align: center;
img{
width: 90px;
height: 90px;
object-fit: cover;
margin: 60px 0 30px;
}
h4{
height: 32px;
font-size: 24px;
font-weight: 500;
color: rgba(0, 0, 0, 0.8500000238418579);
line-height: 32px;
}
p{
height: 22px;
font-size: 14px;
font-weight: 400;
color: rgba(0, 0, 0, 0.44999998807907104);
line-height: 22px;
margin:10px 0;
}
button{
width:260px;
margin: 30px;
}
}
.tips-info-box{
color: #858585;
font-size: 12px;
margin: 15px 30px;
}
}
.tabs {
height: 40px;
margin-top: 15px;
border-bottom: 1px solid #ececec;
box-sizing: border-box;
padding: 7px;
position: relative;
.tabs-nav {
li {
float: left;
color: #666;
font-size: 12px;
user-select: none;
height: 25px;
line-height: 25px;
cursor: pointer;
&.spacer-li {
width: 16px;
span {
margin-left: 6px;
}
}
}
li.active {
color: #272829;
font-size: 13px;
font-weight: bold;
}
}
.tabs-slider {
.base-slider;
}
}
.app-ul {
padding-left: 60px;
background: #fff;
padding-bottom: 38px;
& > li {
float: left;
height: 100px;
width: (1/6) * 100%;
padding-right: 60px;
margin-top: 38px;
text-align: center;
box-sizing: border-box;
a {
.fix;
display: block;
text-decoration: none;
color: #272829;
height: 64px;
width: 64px;
margin: 0 auto;
// box-shadow:0px 10px 20px 0px rgba(0,0,0,0.05);
border-radius: 4px;
overflow: hidden;
outline: none;
background: linear-gradient(
180deg,
#f8f8f9 0%,
#ffffff 21%,
#f7f7f7 100%
);
box-shadow: 0px 10px 20px 0px rgba(227, 230, 236, 0.33);
border-radius: 10px;
border: 1px solid #eaedf1;
// border-image: linear-gradient(180deg, rgba(228, 231, 237, 1), rgba(228, 231, 237, 1)) 1 1;
.icon-box {
width: 44px;
height: 44px;
text-align: center;
margin: 11px;
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
}
&:hover {
// box-shadow:0 0 15px red;-moz-transition:all .3s;-webkit-transition:all .3s;-transition:all .3s;
// background: darken($color: #fff, $amount: 8) ;
}
}
h4.info {
font-size: 14px;
line-height: 16px;
height: 16px;
margin-top: 12px;
color: #272829;
.text-ellipsis;
}
}
}
.app-list-wrapper {
.main-wrapper;
box-shadow: 0px 10px 20px 0px rgba(0, 0, 0, 0.04);
border-radius: 4px;
margin-top: 18px;
margin-bottom: 20px;
}
.wait-app-wrapper {
.main-wrapper;
box-shadow: 0px 10px 20px 0px rgba(0, 0, 0, 0.04);
border-radius: 4px;
margin-top: 20px;
margin-bottom: 30px;
h3.title {
height: 61px;
line-height: 25px;
font-size: 18px;
font-weight: bold;
background: #fff;
text-align: center;
padding: 18px;
box-sizing: border-box;
border-bottom: 1px solid #ececec;
}
}
.footer-container {
background: #374a5f;
width: 100%;
height: 98px;
p {
font-size: 12px;
font-weight: 400;
color: rgba(255, 255, 255, 0.35);
text-align: center;
font-family: SimSun;
font-family: SourceHanSansCN-Regular, SourceHanSansCN;
a{
color: rgba(255, 255, 255, 0.35);
text-decoration: none;
}
}
}
.footer-container.theme-light {
background: #fff;
p {
color: #272829;
a{
color: #272829;
text-decoration: none;
}
}
}
.swiper-container {
min-height: 480px;
}
.swiper-slide {
transform: translate3d(0, 0, 0);
}
.swiper-pagination {
position: absolute;
left: 0;
text-align: center;
bottom: 5px;
width: 100%;
}
.swiper-pagination-bullet {
margin: 0 3px;
width: 8px;
height: 8px;
display: inline-block;
border-radius: 50%;
background: #000;
opacity: 0.2;
}
.swiper-pagination-bullet-active {
background: #f1f1f1;
opacity: 1;
}
+79
View File
@@ -0,0 +1,79 @@
/* 移除不必要的部分 */
@media (max-width: 768px) {
body{
min-width: 100%;
}
html,body,#app{
height: 100%;
}
.app-down,.banner-box{display: none;}
.footer-container{
height: 58px;
}
header.header-wrapper{
height: 58px;
padding:0 10px;
}
.banner-wrapper{
padding-top:48px;
height: calc(100% - 105px);
}
.banner-box, .tabs, .app-list-wrapper, .wait-app-wrapper {
display: none !important;
}
.main-wrapper {
width: 100% !important;
margin: 0 auto !important;
}
.login-wrapper {
width: 90% !important;
margin: 0 auto !important ;
float: none !important;
}
.login-header ul {
padding: 14px 0 !important;
}
.login-header ul li {
margin-right: 10px !important;
}
.login-form {
margin: 20px 15px !important;
}
.login-form .form-item {
margin-top: 15px !important;
}
.login-form .icon-box {
width: 40px !important;
height: 40px !important;
}
.login-form p.input-box input {
padding: 4px 10px 4px 12px !important;
}
.operate-box {
margin: 12px 0 !important;
}
.operate-box a {
font-size: 12px !important;
}
.btn-submit {
height: 40px !important;
line-height: 40px !important;
font-size: 14px !important;
margin-bottom: 10px !important;
}
}
+1
View File
@@ -0,0 +1 @@
article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block;}audio,canvas,video{display:inline-block;}audio:not([controls]){display:none;height:0;}[hidden]{display:none;}html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;}body{margin:0;}a:focus{outline:thin dotted;}a:active,a:hover{outline:0;}h1{font-size:2em;margin:0.67em 0;}abbr[title]{border-bottom:1px dotted;}b,strong{font-weight:bold;}dfn{font-style:italic;}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0;}mark{background:#ff0;color:#000;}code,kbd,pre,samp{font-family:monospace,serif;font-size:1em;}pre{white-space:pre-wrap;}q{quotes:"\201C" "\201D" "\2018" "\2019";}small{font-size:80%;}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline;}sup{top:-0.5em;}sub{bottom:-0.25em;}img{border:0;}svg:not(:root){overflow:hidden;}figure{margin:0;}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em;}legend{border:0;padding:0;}button,input,select,textarea{font-family:inherit;font-size:100%;margin:0;}button,input{line-height:normal;}button,select{text-transform:none;}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer;}button[disabled],html input[disabled]{cursor:default;}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0;}input[type="search"]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box;}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none;}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;}textarea{overflow:auto;vertical-align:top;}table{border-collapse:collapse;border-spacing:0;}
+133
View File
@@ -0,0 +1,133 @@
/* http://meyerweb.com/eric/tools/css/reset/ v2.0 | 20110126 License: none (public domain)*/
.html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
line-height: 1.5;
} /* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
line-height: 1;
}
ol,
ul {
list-style: none;
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: "";
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
+74
View File
@@ -0,0 +1,74 @@
// utils 工具
.text-black{color:#000}
.text-dark{color:#333}
.text-deep{color:#555}
.text-weak{color:#B3B3B3}
.text-white{color:#fff}
// 文字对齐
.tl{text-align:left}
.tc{text-align:center}
.tr{text-align:right}
// 浮动与清除浮动
.fl{float:left}
.fr{float:right}
.fix{*zoom:1}
.fix:after{display:table;content:'';clear:both}
// 显示
.dn{display:none}
.di{display:inline}
.db{display:block}
.dib{display:inline-block}
.dt{display:table}
div.dib{*display:inline;*zoom:1}
.vm{vertical-align:middle}
.vib{display:inline-block;vertical-align:middle}
// 定位
.pr{position:relative}
.pa{position:absolute}
.pf{position:fixed}
.for(@count){
.width-@{count} { width: @count *1%;}
.height-@{count} { height: @count *1%;}
.ml-@{count} { margin-left: @count * 1px; }
.mr-@{count} { margin-right: @count * 1px; }
.mt-@{count} { margin-top: @count* 1px; }
.mb-@{count} { margin-bottom: @count * 1px; }
}
.loop(50);
.loop(@n, @i: 0) when (@i =< @n) {
.ml-@{i} { margin-left: @i * 1px !important; }
.mr-@{i} { margin-right: @i * 1px!important; }
.mt-@{i} { margin-top: @i* 1px!important; }
.mb-@{i} { margin-bottom: @i * 1px!important; }
.loop(@n, (@i+1));
}
.text-ellipsis{
overflow: hidden;white-space: nowrap;text-overflow: ellipsis;
}
.col-12{width:100%}
.col-11{width:91.66666667%}
.col-10{width:83.33333333%}
.col-9{width:75%}
.col-8{width:66.66666667%}
.col-7{width:58.33333333%}
.col-6{width:50%}
.col-5{width:41.66666667%}
.col-4{width:33.33333333%}
.col-3{width:25%}
.col-2{width:16.66666667%}
.col-1{width:8.33333333%}
.col-4, .col-8, .col-12{
position: relative;
min-height: 1px;
padding: 10px;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
/*! npm.im/object-fit-images 3.2.4 */
var objectFitImages=function(){"use strict";function t(t,e){return"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='"+t+"' height='"+e+"'%3E%3C/svg%3E"}function e(t){if(t.srcset&&!p&&window.picturefill){var e=window.picturefill._;t[e.ns]&&t[e.ns].evaled||e.fillImg(t,{reselect:!0}),t[e.ns].curSrc||(t[e.ns].supported=!1,e.fillImg(t,{reselect:!0})),t.currentSrc=t[e.ns].curSrc||t.src}}function i(t){for(var e,i=getComputedStyle(t).fontFamily,r={};null!==(e=u.exec(i));)r[e[1]]=e[2];return r}function r(e,i,r){var n=t(i||1,r||0);b.call(e,"src")!==n&&h.call(e,"src",n)}function n(t,e){t.naturalWidth?e(t):setTimeout(n,100,t,e)}function c(t){var c=i(t),o=t[l];if(c["object-fit"]=c["object-fit"]||"fill",!o.img){if("fill"===c["object-fit"])return;if(!o.skipTest&&f&&!c["object-position"])return}if(!o.img){o.img=new Image(t.width,t.height),o.img.srcset=b.call(t,"data-ofi-srcset")||t.srcset,o.img.src=b.call(t,"data-ofi-src")||t.src,h.call(t,"data-ofi-src",t.src),t.srcset&&h.call(t,"data-ofi-srcset",t.srcset),r(t,t.naturalWidth||t.width,t.naturalHeight||t.height),t.srcset&&(t.srcset="");try{s(t)}catch(t){window.console&&console.warn("https://bit.ly/ofi-old-browser")}}e(o.img),t.style.backgroundImage='url("'+(o.img.currentSrc||o.img.src).replace(/"/g,'\\"')+'")',t.style.backgroundPosition=c["object-position"]||"center",t.style.backgroundRepeat="no-repeat",t.style.backgroundOrigin="content-box",/scale-down/.test(c["object-fit"])?n(o.img,function(){o.img.naturalWidth>t.width||o.img.naturalHeight>t.height?t.style.backgroundSize="contain":t.style.backgroundSize="auto"}):t.style.backgroundSize=c["object-fit"].replace("none","auto").replace("fill","100% 100%"),n(o.img,function(e){r(t,e.naturalWidth,e.naturalHeight)})}function s(t){var e={get:function(e){return t[l].img[e?e:"src"]},set:function(e,i){return t[l].img[i?i:"src"]=e,h.call(t,"data-ofi-"+i,e),c(t),e}};Object.defineProperty(t,"src",e),Object.defineProperty(t,"currentSrc",{get:function(){return e.get("currentSrc")}}),Object.defineProperty(t,"srcset",{get:function(){return e.get("srcset")},set:function(t){return e.set(t,"srcset")}})}function o(){function t(t,e){return t[l]&&t[l].img&&("src"===e||"srcset"===e)?t[l].img:t}d||(HTMLImageElement.prototype.getAttribute=function(e){return b.call(t(this,e),e)},HTMLImageElement.prototype.setAttribute=function(e,i){return h.call(t(this,e),e,String(i))})}function a(t,e){var i=!y&&!t;if(e=e||{},t=t||"img",d&&!e.skipTest||!m)return!1;"img"===t?t=document.getElementsByTagName("img"):"string"==typeof t?t=document.querySelectorAll(t):"length"in t||(t=[t]);for(var r=0;r<t.length;r++)t[r][l]=t[r][l]||{skipTest:e.skipTest},c(t[r]);i&&(document.body.addEventListener("load",function(t){"IMG"===t.target.tagName&&a(t.target,{skipTest:e.skipTest})},!0),y=!0,t="img"),e.watchMQ&&window.addEventListener("resize",a.bind(null,t,{skipTest:e.skipTest}))}var l="fregante:object-fit-images",u=/(object-fit|object-position)\s*:\s*([-.\w\s%]+)/g,g="undefined"==typeof Image?{style:{"object-position":1}}:new Image,f="object-fit"in g.style,d="object-position"in g.style,m="background-size"in g.style,p="string"==typeof g.currentSrc,b=g.getAttribute,h=g.setAttribute,y=!1;return a.supportsObjectFit=f,a.supportsObjectPosition=d,o(),a}();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+571
View File
@@ -0,0 +1,571 @@
/**
* Swiper 3.4.2
* Most modern mobile touch slider and framework with hardware accelerated transitions
*
* http://www.idangero.us/swiper/
*
* Copyright 2017, Vladimir Kharlampidi
* The iDangero.us
* http://www.idangero.us/
*
* Licensed under MIT
*
* Released on: March 10, 2017
*/
.swiper-container {
margin-left: auto;
margin-right: auto;
position: relative;
overflow: hidden;
z-index: 1
}
.swiper-container-no-flexbox .swiper-slide {
float: left
}
.swiper-container-vertical>.swiper-wrapper {
-webkit-box-orient: vertical;
-moz-box-orient: vertical;
-ms-flex-direction: column;
-webkit-flex-direction: column;
flex-direction: column
}
.swiper-wrapper {
position: relative;
width: 100%;
height: 100%;
z-index: 1;
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-webkit-transition-property: -webkit-transform;
-moz-transition-property: -moz-transform;
-o-transition-property: -o-transform;
-ms-transition-property: -ms-transform;
transition-property: transform;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box
}
.swiper-container-android .swiper-slide,.swiper-wrapper {
-webkit-transform: translate3d(0,0,0);
-moz-transform: translate3d(0,0,0);
-o-transform: translate(0,0);
-ms-transform: translate3d(0,0,0);
transform: translate3d(0,0,0)
}
.swiper-container-multirow>.swiper-wrapper {
-webkit-box-lines: multiple;
-moz-box-lines: multiple;
-ms-flex-wrap: wrap;
-webkit-flex-wrap: wrap;
flex-wrap: wrap
}
.swiper-container-free-mode>.swiper-wrapper {
-webkit-transition-timing-function: ease-out;
-moz-transition-timing-function: ease-out;
-ms-transition-timing-function: ease-out;
-o-transition-timing-function: ease-out;
transition-timing-function: ease-out;
margin: 0 auto
}
.swiper-slide {
-webkit-flex-shrink: 0;
-ms-flex: 0 0 auto;
flex-shrink: 0;
width: 100%;
height: 100%;
position: relative
}
.swiper-container-autoheight,.swiper-container-autoheight .swiper-slide {
height: auto
}
.swiper-container-autoheight .swiper-wrapper {
-webkit-box-align: start;
-ms-flex-align: start;
-webkit-align-items: flex-start;
align-items: flex-start;
-webkit-transition-property: -webkit-transform,height;
-moz-transition-property: -moz-transform;
-o-transition-property: -o-transform;
-ms-transition-property: -ms-transform;
transition-property: transform,height
}
.swiper-container .swiper-notification {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
opacity: 0;
z-index: -1000
}
.swiper-wp8-horizontal {
-ms-touch-action: pan-y;
touch-action: pan-y
}
.swiper-wp8-vertical {
-ms-touch-action: pan-x;
touch-action: pan-x
}
.swiper-button-next,.swiper-button-prev {
position: absolute;
top: 50%;
width: 27px;
height: 44px;
margin-top: -22px;
z-index: 10;
cursor: pointer;
-moz-background-size: 27px 44px;
-webkit-background-size: 27px 44px;
background-size: 27px 44px;
background-position: center;
background-repeat: no-repeat
}
.swiper-button-next.swiper-button-disabled,.swiper-button-prev.swiper-button-disabled {
opacity: .35;
cursor: auto;
pointer-events: none
}
.swiper-button-prev,.swiper-container-rtl .swiper-button-next {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23007aff'%2F%3E%3C%2Fsvg%3E");
left: 10px;
right: auto
}
.swiper-button-prev.swiper-button-black,.swiper-container-rtl .swiper-button-next.swiper-button-black {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23000000'%2F%3E%3C%2Fsvg%3E")
}
.swiper-button-prev.swiper-button-white,.swiper-container-rtl .swiper-button-next.swiper-button-white {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23ffffff'%2F%3E%3C%2Fsvg%3E")
}
.swiper-button-next,.swiper-container-rtl .swiper-button-prev {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23007aff'%2F%3E%3C%2Fsvg%3E");
right: 10px;
left: auto
}
.swiper-button-next.swiper-button-black,.swiper-container-rtl .swiper-button-prev.swiper-button-black {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23000000'%2F%3E%3C%2Fsvg%3E")
}
.swiper-button-next.swiper-button-white,.swiper-container-rtl .swiper-button-prev.swiper-button-white {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23ffffff'%2F%3E%3C%2Fsvg%3E")
}
.swiper-pagination {
position: absolute;
text-align: center;
-webkit-transition: .3s;
-moz-transition: .3s;
-o-transition: .3s;
transition: .3s;
-webkit-transform: translate3d(0,0,0);
-ms-transform: translate3d(0,0,0);
-o-transform: translate3d(0,0,0);
transform: translate3d(0,0,0);
z-index: 10
}
.swiper-pagination.swiper-pagination-hidden {
opacity: 0
}
.swiper-container-horizontal>.swiper-pagination-bullets,.swiper-pagination-custom,.swiper-pagination-fraction {
bottom: 10px;
left: 0;
width: 100%
}
.swiper-pagination-bullet {
width: 8px;
height: 8px;
display: inline-block;
border-radius: 100%;
background: #000;
opacity: .2
}
button.swiper-pagination-bullet {
border: none;
margin: 0;
padding: 0;
box-shadow: none;
-moz-appearance: none;
-ms-appearance: none;
-webkit-appearance: none;
appearance: none
}
.swiper-pagination-clickable .swiper-pagination-bullet {
cursor: pointer
}
.swiper-pagination-white .swiper-pagination-bullet {
background: #fff
}
.swiper-pagination-bullet-active {
opacity: 1;
background: #007aff
}
.swiper-pagination-white .swiper-pagination-bullet-active {
background: #fff
}
.swiper-pagination-black .swiper-pagination-bullet-active {
background: #000
}
.swiper-container-vertical>.swiper-pagination-bullets {
right: 10px;
top: 50%;
-webkit-transform: translate3d(0,-50%,0);
-moz-transform: translate3d(0,-50%,0);
-o-transform: translate(0,-50%);
-ms-transform: translate3d(0,-50%,0);
transform: translate3d(0,-50%,0)
}
.swiper-container-vertical>.swiper-pagination-bullets .swiper-pagination-bullet {
margin: 5px 0;
display: block
}
.swiper-container-horizontal>.swiper-pagination-bullets .swiper-pagination-bullet {
margin: 0 5px
}
.swiper-pagination-progress {
background: rgba(0,0,0,.25);
position: absolute
}
.swiper-pagination-progress .swiper-pagination-progressbar {
background: #007aff;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
-webkit-transform: scale(0);
-ms-transform: scale(0);
-o-transform: scale(0);
transform: scale(0);
-webkit-transform-origin: left top;
-moz-transform-origin: left top;
-ms-transform-origin: left top;
-o-transform-origin: left top;
transform-origin: left top
}
.swiper-container-rtl .swiper-pagination-progress .swiper-pagination-progressbar {
-webkit-transform-origin: right top;
-moz-transform-origin: right top;
-ms-transform-origin: right top;
-o-transform-origin: right top;
transform-origin: right top
}
.swiper-container-horizontal>.swiper-pagination-progress {
width: 100%;
height: 4px;
left: 0;
top: 0
}
.swiper-container-vertical>.swiper-pagination-progress {
width: 4px;
height: 100%;
left: 0;
top: 0
}
.swiper-pagination-progress.swiper-pagination-white {
background: rgba(255,255,255,.5)
}
.swiper-pagination-progress.swiper-pagination-white .swiper-pagination-progressbar {
background: #fff
}
.swiper-pagination-progress.swiper-pagination-black .swiper-pagination-progressbar {
background: #000
}
.swiper-container-3d {
-webkit-perspective: 1200px;
-moz-perspective: 1200px;
-o-perspective: 1200px;
perspective: 1200px
}
.swiper-container-3d .swiper-cube-shadow,.swiper-container-3d .swiper-slide,.swiper-container-3d .swiper-slide-shadow-bottom,.swiper-container-3d .swiper-slide-shadow-left,.swiper-container-3d .swiper-slide-shadow-right,.swiper-container-3d .swiper-slide-shadow-top,.swiper-container-3d .swiper-wrapper {
-webkit-transform-style: preserve-3d;
-moz-transform-style: preserve-3d;
-ms-transform-style: preserve-3d;
transform-style: preserve-3d
}
.swiper-container-3d .swiper-slide-shadow-bottom,.swiper-container-3d .swiper-slide-shadow-left,.swiper-container-3d .swiper-slide-shadow-right,.swiper-container-3d .swiper-slide-shadow-top {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10
}
.swiper-container-3d .swiper-slide-shadow-left {
background-image: -webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,0)));
background-image: -webkit-linear-gradient(right,rgba(0,0,0,.5),rgba(0,0,0,0));
background-image: -moz-linear-gradient(right,rgba(0,0,0,.5),rgba(0,0,0,0));
background-image: -o-linear-gradient(right,rgba(0,0,0,.5),rgba(0,0,0,0));
background-image: linear-gradient(to left,rgba(0,0,0,.5),rgba(0,0,0,0))
}
.swiper-container-3d .swiper-slide-shadow-right {
background-image: -webkit-gradient(linear,right top,left top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,0)));
background-image: -webkit-linear-gradient(left,rgba(0,0,0,.5),rgba(0,0,0,0));
background-image: -moz-linear-gradient(left,rgba(0,0,0,.5),rgba(0,0,0,0));
background-image: -o-linear-gradient(left,rgba(0,0,0,.5),rgba(0,0,0,0));
background-image: linear-gradient(to right,rgba(0,0,0,.5),rgba(0,0,0,0))
}
.swiper-container-3d .swiper-slide-shadow-top {
background-image: -webkit-gradient(linear,left top,left bottom,from(rgba(0,0,0,.5)),to(rgba(0,0,0,0)));
background-image: -webkit-linear-gradient(bottom,rgba(0,0,0,.5),rgba(0,0,0,0));
background-image: -moz-linear-gradient(bottom,rgba(0,0,0,.5),rgba(0,0,0,0));
background-image: -o-linear-gradient(bottom,rgba(0,0,0,.5),rgba(0,0,0,0));
background-image: linear-gradient(to top,rgba(0,0,0,.5),rgba(0,0,0,0))
}
.swiper-container-3d .swiper-slide-shadow-bottom {
background-image: -webkit-gradient(linear,left bottom,left top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,0)));
background-image: -webkit-linear-gradient(top,rgba(0,0,0,.5),rgba(0,0,0,0));
background-image: -moz-linear-gradient(top,rgba(0,0,0,.5),rgba(0,0,0,0));
background-image: -o-linear-gradient(top,rgba(0,0,0,.5),rgba(0,0,0,0));
background-image: linear-gradient(to bottom,rgba(0,0,0,.5),rgba(0,0,0,0))
}
.swiper-container-coverflow .swiper-wrapper,.swiper-container-flip .swiper-wrapper {
-ms-perspective: 1200px
}
.swiper-container-cube,.swiper-container-flip {
overflow: visible
}
.swiper-container-cube .swiper-slide,.swiper-container-flip .swiper-slide {
pointer-events: none;
-webkit-backface-visibility: hidden;
-moz-backface-visibility: hidden;
-ms-backface-visibility: hidden;
backface-visibility: hidden;
z-index: 1
}
.swiper-container-cube .swiper-slide .swiper-slide,.swiper-container-flip .swiper-slide .swiper-slide {
pointer-events: none
}
.swiper-container-cube .swiper-slide-active,.swiper-container-cube .swiper-slide-active .swiper-slide-active,.swiper-container-flip .swiper-slide-active,.swiper-container-flip .swiper-slide-active .swiper-slide-active {
pointer-events: auto
}
.swiper-container-cube .swiper-slide-shadow-bottom,.swiper-container-cube .swiper-slide-shadow-left,.swiper-container-cube .swiper-slide-shadow-right,.swiper-container-cube .swiper-slide-shadow-top,.swiper-container-flip .swiper-slide-shadow-bottom,.swiper-container-flip .swiper-slide-shadow-left,.swiper-container-flip .swiper-slide-shadow-right,.swiper-container-flip .swiper-slide-shadow-top {
z-index: 0;
-webkit-backface-visibility: hidden;
-moz-backface-visibility: hidden;
-ms-backface-visibility: hidden;
backface-visibility: hidden
}
.swiper-container-cube .swiper-slide {
visibility: hidden;
-webkit-transform-origin: 0 0;
-moz-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
width: 100%;
height: 100%
}
.swiper-container-cube.swiper-container-rtl .swiper-slide {
-webkit-transform-origin: 100% 0;
-moz-transform-origin: 100% 0;
-ms-transform-origin: 100% 0;
transform-origin: 100% 0
}
.swiper-container-cube .swiper-slide-active,.swiper-container-cube .swiper-slide-next,.swiper-container-cube .swiper-slide-next+.swiper-slide,.swiper-container-cube .swiper-slide-prev {
pointer-events: auto;
visibility: visible
}
.swiper-container-cube .swiper-cube-shadow {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 100%;
background: #000;
opacity: .6;
-webkit-filter: blur(50px);
filter: blur(50px);
z-index: 0
}
.swiper-container-fade.swiper-container-free-mode .swiper-slide {
-webkit-transition-timing-function: ease-out;
-moz-transition-timing-function: ease-out;
-ms-transition-timing-function: ease-out;
-o-transition-timing-function: ease-out;
transition-timing-function: ease-out
}
.swiper-container-fade .swiper-slide {
pointer-events: none;
-webkit-transition-property: opacity;
-moz-transition-property: opacity;
-o-transition-property: opacity;
transition-property: opacity
}
.swiper-container-fade .swiper-slide .swiper-slide {
pointer-events: none
}
.swiper-container-fade .swiper-slide-active,.swiper-container-fade .swiper-slide-active .swiper-slide-active {
pointer-events: auto
}
.swiper-zoom-container {
width: 100%;
height: 100%;
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-webkit-box-pack: center;
-moz-box-pack: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
-webkit-box-align: center;
-moz-box-align: center;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
text-align: center
}
.swiper-zoom-container>canvas,.swiper-zoom-container>img,.swiper-zoom-container>svg {
max-width: 100%;
max-height: 100%;
object-fit: contain
}
.swiper-scrollbar {
border-radius: 10px;
position: relative;
-ms-touch-action: none;
background: rgba(0,0,0,.1)
}
.swiper-container-horizontal>.swiper-scrollbar {
position: absolute;
left: 1%;
bottom: 3px;
z-index: 50;
height: 5px;
width: 98%
}
.swiper-container-vertical>.swiper-scrollbar {
position: absolute;
right: 3px;
top: 1%;
z-index: 50;
width: 5px;
height: 98%
}
.swiper-scrollbar-drag {
height: 100%;
width: 100%;
position: relative;
background: rgba(0,0,0,.5);
border-radius: 10px;
left: 0;
top: 0
}
.swiper-scrollbar-cursor-drag {
cursor: move
}
.swiper-lazy-preloader {
width: 42px;
height: 42px;
position: absolute;
left: 50%;
top: 50%;
margin-left: -21px;
margin-top: -21px;
z-index: 10;
-webkit-transform-origin: 50%;
-moz-transform-origin: 50%;
transform-origin: 50%;
-webkit-animation: swiper-preloader-spin 1s steps(12,end) infinite;
-moz-animation: swiper-preloader-spin 1s steps(12,end) infinite;
animation: swiper-preloader-spin 1s steps(12,end) infinite
}
.swiper-lazy-preloader:after {
display: block;
content: "";
width: 100%;
height: 100%;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D'0%200%20120%20120'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20xmlns%3Axlink%3D'http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink'%3E%3Cdefs%3E%3Cline%20id%3D'l'%20x1%3D'60'%20x2%3D'60'%20y1%3D'7'%20y2%3D'27'%20stroke%3D'%236c6c6c'%20stroke-width%3D'11'%20stroke-linecap%3D'round'%2F%3E%3C%2Fdefs%3E%3Cg%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(30%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(60%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(90%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(120%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(150%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.37'%20transform%3D'rotate(180%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.46'%20transform%3D'rotate(210%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.56'%20transform%3D'rotate(240%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.66'%20transform%3D'rotate(270%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.75'%20transform%3D'rotate(300%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.85'%20transform%3D'rotate(330%2060%2C60)'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
background-position: 50%;
-webkit-background-size: 100%;
background-size: 100%;
background-repeat: no-repeat
}
.swiper-lazy-preloader-white:after {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D'0%200%20120%20120'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20xmlns%3Axlink%3D'http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink'%3E%3Cdefs%3E%3Cline%20id%3D'l'%20x1%3D'60'%20x2%3D'60'%20y1%3D'7'%20y2%3D'27'%20stroke%3D'%23fff'%20stroke-width%3D'11'%20stroke-linecap%3D'round'%2F%3E%3C%2Fdefs%3E%3Cg%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(30%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(60%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(90%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(120%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(150%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.37'%20transform%3D'rotate(180%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.46'%20transform%3D'rotate(210%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.56'%20transform%3D'rotate(240%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.66'%20transform%3D'rotate(270%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.75'%20transform%3D'rotate(300%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.85'%20transform%3D'rotate(330%2060%2C60)'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")
}
@-webkit-keyframes swiper-preloader-spin {
100% {
-webkit-transform: rotate(360deg)
}
}
@keyframes swiper-preloader-spin {
100% {
transform: rotate(360deg)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long