Commit 603e4fc4 authored by Sacen's avatar Sacen

完善统一认证与会话安全并改版前端界面

新增数字证书登录、登录 IP 绑定、会话与授权撤销、密码修改和账号安全策略;登录页与应用中心改为浅色界面,桌面网格调整为 8 列并优化自适应布局;同时增强用户导入校验与审计日志。
Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
parent 0c09b3d1
......@@ -3,7 +3,7 @@
"configurations": [
{
"name": "zhihui-web",
"runtimeExecutable": "C:/nvm4w/nodejs/node.exe",
"runtimeExecutable": "E:/install/npm/node.exe",
"runtimeArgs": [
"zhihui-web/node_modules/vite/bin/vite.js",
"zhihui-web",
......
......@@ -8,16 +8,19 @@
- 门户账号登录:本地用户库、BCrypt 密码、管理员/普通用户两种角色;除 `admin` / `demo` 外的账号支持警号、身份证号、手机号三种登录标识,`admin` / `demo` 使用用户名登录
- 标准 OIDC / OAuth2 认证中心:授权码 + PKCE、JWT、JWKS、UserInfo、RP-Initiated Logout
- 应用市场:应用由管理员统一上架,用户从市场挑选应用添加到自己的门户,图标式展示,点击新标签页直达
- 智能体广场:应用由管理员统一上架,用户从智能体广场挑选应用添加到自己的门户,图标式展示,点击新标签页直达
- 个人应用与布局:用户可添加/移除自己的应用,并在固定网格中把图标拖到任意格子;只影响本人,不影响子系统和其他用户
- 应用市场管理:管理员可在首页“管理”模式或管理后台新增、编辑、删除应用;client_secret 加密保存,管理员可在“接入信息”中查看和复制,也可编辑 client_id、client_secret、回调地址和登出回调地址,点击保存时弹窗验证当前管理员密码;支持重置与删除
- 智能体广场管理:管理员在管理后台新增、编辑、删除应用;client_secret 加密保存,管理员可在“接入信息”中查看和复制,也可编辑 client_id、client_secret、回调地址和登出回调地址,点击保存时弹窗验证当前管理员密码;支持重置与删除
- 用户管理:新增、编辑、重置密码、启用/停用、删除,含警号、身份证号、手机号登录字段与 Excel 批量导入/删除;警号、身份证号、手机号用于子系统本地用户匹配,普通用户新增/导入时警号必填
- 个人中心:展示姓名、警号、身份证号、手机号、登录账号、邮箱和角色;邮箱可由本人自助修改,其他字段只读,未填写的标识显示“未填写”
- 登录安全:按“登录标识+IP”和“IP”双维度限流,失败计数存 Redis,默认 15 分钟窗口
- 审计日志:记录登录成功/失败/限流/退出、用户与应用管理操作,管理员可在后台筛选查询;失败登录中的身份证号、手机号会脱敏
- 已有 Java 系统接入包(`zhihui-sso-starter`):自动发现 OIDC 配置、登录跳转、回调校验、会话建立、退出;向业务代码提供含警号、身份证号、手机号的 `SsoUser`。`admin` / `demo` 禁止进入子系统,其他账号由子系统按这三个标识匹配本地用户;用户名、`sub`、`roles` 和显示名不得作为授权依据
- 两个示例子系统(`zhihui-demo-app` 多实例):完整演示免登、退出、普通用户按警号/身份证号/手机号中的任一标识匹配本地用户、admin/demo 无身份 claims 并由本地校验提示未开通、应用市场添加与个人网格布局
- 会话存 Redis,授权记录与客户端存 MySQL,签名密钥持久化,重启不掉线
- 登录安全:按“登录标识+IP”和“IP”双维度限流,失败计数存 Redis,默认 15 分钟窗口;账号密码与数字证书登录都会记录登录方式和来源 IP,登录会话与 IP 绑定,IP 变更时立即撤销该用户全部会话和子系统授权
- 审计日志:记录登录成功/失败/限流/退出、登录方式(账号密码/数字证书)、登录 IP 变更、用户与应用管理操作,管理员可在后台筛选查询;失败登录中的身份证号、手机号会脱敏
- 已有 Java 系统接入包(`zhihui-sso-starter`):自动发现 OIDC 配置、登录跳转、回调校验、会话建立、退出;向业务代码提供含警号、身份证号、手机号的 `SsoUser`。会话默认每 60 秒调用 UserInfo 重校验,Access Token 失效时自动用 Refresh Token 续期,续期失败则结束本地会话。`admin` / `demo` 禁止进入子系统,其他账号由子系统按这三个标识匹配本地用户;用户名、`sub`、`roles` 和显示名不得作为授权依据
- 应用访问控制:发起 OAuth 授权时校验应用已上架且在当前用户的个人门户中;`admin` / `demo` 及未添加该应用的账号直接收到 403,不下发授权码
- 会话与授权撤销:用户改角色、停用、删除、重置密码、修改登录标识或退出时,立即清理其 Redis 会话和 OAuth 授权;应用下架或用户移除应用时,同步撤销对应授权
- 两个示例子系统(`zhihui-demo-app` 多实例):完整演示免登、退出、普通用户按警号/身份证号/手机号中的任一标识匹配本地用户、admin/demo 由门户在授权端点直接拒绝、智能体广场添加与个人网格布局
- 会话存 Redis(索引模式),授权记录与客户端存 MySQL,签名密钥持久化,重启不掉线
- 启动日志不打印任何默认密码、客户端密钥或用户口令
## 架构
......@@ -106,12 +109,13 @@ cp .env.example .env
| `ZHIHUI_DEMO_LOCAL_POLICE_NUMBER` | `SMK660909` | 示例子系统本地用户的警号 |
| `ZHIHUI_DEMO_LOCAL_ID_CARD_NUMBER` | `110101199001011234` | 示例子系统本地用户的身份证号 |
| `ZHIHUI_DEMO_LOCAL_PHONE_NUMBER` | `13800138000` | 示例子系统本地用户的手机号 |
| `ZHIHUI_SSO_SESSION_REVALIDATE_INTERVAL` | `60s` | 子系统 Starter 调用 UserInfo 重校验会话的间隔 |
| `ZHIHUI_COOKIE_SECURE` | `false` | HTTPS 环境改为 `true` |
| `ZHIHUI_LOGIN_MAX_FAILURES_PER_USER_IP` | `5` | 同一登录标识+IP 在窗口内的最大失败次数 |
| `ZHIHUI_LOGIN_MAX_FAILURES_PER_IP` | `20` | 同一 IP 在窗口内的最大失败次数 |
| `ZHIHUI_LOGIN_FAILURE_WINDOW` | `15m` | 失败计数/限流窗口 |
| `ZHIHUI_LOGIN_RATE_LIMIT_ENABLED` | `true` | 是否启用登录限流 |
| `ZHIHUI_TRUST_FORWARDED_HEADERS` | `false` | 仅在可信反向代理后开启,用于获取真实客户端 IP |
| `ZHIHUI_TRUST_FORWARDED_HEADERS` | `false` | 仅在可信反向代理后开启,用于解析 `X-Forwarded-For` / `X-Real-IP` 获取真实客户端 IP |
### 4. 构建
......@@ -153,19 +157,19 @@ bash scripts/run-demo2.sh
bash scripts/smoke-test.sh
```
脚本会验证:健康检查 → admin/demo 无身份 claims 并由子系统提示未开通 → 临时普通账号登录 → OIDC 与 UserInfo 返回警号/身份证号/手机号 → 两个示例子系统按三标识免登 → 清理临时账号。
脚本会验证:健康检查 → admin/demo 请求授权被门户拒绝(403)→ 临时普通账号登录并添加示例应用 → OIDC 与 UserInfo 返回警号/身份证号/手机号 → 两个示例子系统按三标识免登 → 清理临时账号。
> 冒烟测试默认使用 `SMK660909`、`110101199001011234`、`13800138000` 作为临时普通账号的标识;如果修改了示例子系统的 `ZHIHUI_DEMO_LOCAL_*`,请同步设置 `ZHIHUI_SMOKE_POLICE`、`ZHIHUI_SMOKE_ID_CARD`、`ZHIHUI_SMOKE_PHONE`。
> 冒烟测试默认使用警号 `SMK660909`、身份证号 `110101199001019999`、手机号 `13800138000` 作为临时普通账号的标识;其中身份证号只用于校验 claims,示例子系统按警号匹配本地用户。如果这些标识已被现有用户占用,或修改了示例子系统的 `ZHIHUI_DEMO_LOCAL_*`,请通过 `ZHIHUI_SMOKE_POLICE`、`ZHIHUI_SMOKE_ID_CARD`、`ZHIHUI_SMOKE_PHONE` 覆盖。
>
> 示例子系统的默认本地用户标识为:警号 `SMK660909`、身份证号 `110101199001011234`、手机号 `13800138000`。在用户管理中把普通账号的任一标识设置为对应值,即可进入示例子系统;`admin`、`demo` 无论怎么设置都不能进入。
手工验证:
1. 用 `admin` 登录门户,进入“应用中心”,应看到“示例子系统”和“示例子系统二”
2. 点击任一应用,新标签页会直接进入对应子系统,不再要求输入密码
3. 所有用户都可按住图标拖到任意格子(空格移动、占用格交换);点“应用市场”可把管理员上架的应用添加到自己的门户,鼠标移到图标上点 × 只会从自己的门户移除;管理员点“管理”维护全局应用
2. 用普通账号登录,先在智能体广场把示例应用添加到自己的门户,再点击图标,新标签页会直接进入对应子系统,不再要求输入密码
3. 所有用户都可按住图标拖到任意格子(空格移动、占用格交换);点“智能体广场”可把管理员上架的应用添加到自己的门户,右键图标或从智能体广场已添加列表移除只会影响自己的门户
4. 在“管理后台 → 用户管理”给普通用户补录警号、身份证号、手机号后,分别用这三种标识登录;`admin` / `demo` 仍使用用户名
5. 用 `admin` 或 `demo` 点击应用,应看到与普通未开通账号一致的“当前账号未开通本系统”;普通账号点击应用可正常免登进入
5. 用 `admin` 或 `demo` 点击应用,会看到门户的“无法访问应用”页面(403),不会跳转到子系统;没有把应用添加到个人门户的普通账号同样会被拒绝
6. 在子系统中点击“退出登录”,会结束统一认证会话并回到登录页
### 7. 前端开发模式
......@@ -181,25 +185,26 @@ Vite 会把 `/api`、`/login` 等请求代理到 8080。开发模式下登录成
1. 用户在门户登录,认证中心在浏览器写入 HttpOnly 会话 Cookie(存 Redis)
2. 用户打开子系统,子系统 Starter 发现本地没有登录用户,302 到认证中心的 `/oauth2/authorize`,携带 `state`、`nonce` 和 PKCE
3. 认证中心发现浏览器已有登录会话,直接返回一次性授权码,不再展示登录页
3. 认证中心发现浏览器已有登录会话,并校验该应用已上架且已添加到当前用户的个人门户,通过后直接返回一次性授权码,不再展示登录页
4. 子系统在**服务端**用授权码 + `client_secret` 换取 ID Token / Access Token
5. 子系统校验 JWT 签名(JWKS)、`iss`、`aud`、`exp`、`nonce`,通过后写入自己的本地会话
6. 之后用户访问该子系统不再经过认证中心,直到本地会话过期或主动退出
6. 之后用户访问该子系统不再经过认证中心;Starter 默认每 60 秒调用一次 UserInfo 重校验,账号被停用、删除或应用被移除后,最迟一个校验周期内失去访问
client_secret 始终只在服务端使用,浏览器全程接触不到。
> 无论登录页是门户直接打开,还是由子系统跳转触发,登录成功后都固定进入门户“应用中心”,不会自动回到子系统;之后点击对应应用即可通过已有会话免登进入。
## 应用市场与个人布局
## 智能体广场与个人布局
- 应用市场由管理员统一维护:管理员新增、编辑、删除应用(也可在管理后台“市场管理”操作),普通用户只能浏览和添加
- 普通用户点“应用市场”,把需要的应用添加到自己的门户;已添加的应用显示“已添加”
- 新上架的应用不会自动出现在任何人的门户,需要用户自己从应用市场添加
- 新建用户默认没有应用,登录后去应用市场添加;演示账号 `admin` / `demo` 在首次初始化时自动加入两个示例子系统用于界面演示,但它们点击应用时会被子系统按普通未开通账号提示
- 智能体广场由管理员统一维护:管理员新增、编辑、删除应用(也可在管理后台“智能体广场管理”操作),普通用户只能浏览和添加
- 普通用户点“智能体广场”,把需要的应用添加到自己的门户;已添加的应用显示“已添加”
- 新上架的应用不会自动出现在任何人的门户,需要用户自己从智能体广场添加
- 新建用户默认没有应用,登录后去智能体广场添加;演示账号 `admin` / `demo` 在首次初始化时自动加入两个示例子系统用于界面演示,但它们点击应用时会被门户在授权端点拒绝(403)
- 授权时会再次校验应用状态和个人选择:应用下架、用户移除应用后,旧的授权记录一并撤销,不能继续凭旧会话进入
- 所有用户登录后可直接按住图标拖到任意格子:拖到空格会移动过去,拖到已占用格会与对方交换;位置按用户独立保存
- 桌面端 6 列、平板 4 列、手机 3 列;空格虚线占位只在拖动过程中显示,平时不显示格子阴影
- 应用图标悬浮时只做轻微上浮,不显示右上角 ×;右键图标选择「卸载应用」,或在应用市场已添加的应用行点「移除」
- 管理员在首页点“管理”可进行全局应用的新增、编辑、删除;全局操作与个人添加/移除互不影响
- 桌面端 8 列、平板 4 列、手机 3 列;空格虚线占位只在拖动过程中显示,平时不显示格子阴影
- 应用图标悬浮时只做轻微上浮,不显示右上角 ×;右键图标选择「卸载应用」,或在智能体广场已添加的应用行点「移除」
- 全局应用维护在“管理后台 → 智能体广场管理”;管理员在应用中心与普通用户完全相同,只能从自己的门户移除应用,不提供首页管理入口
- 个人数据:格子位置存在 `user_launcher_order.sort_order`(从 0 开始,空格不落库),已添加的应用在 `user_app_selection`(V5 已取代 `user_hidden_app`;V7 已把旧排序压缩为格子索引)
- 新添加但尚未摆放的应用会追加到现有布局之后的空格
......@@ -218,16 +223,17 @@ client_secret 始终只在服务端使用,浏览器全程接触不到。
- 身份证号、手机号为可选字段;未填写时只是不能使用该方式登录。普通用户的警号仍然必填。
- 除 `admin`、`demo` 外,其他账号应至少填写警号、身份证号、手机号中的一项,否则没有可用的登录标识;`ADMIN` 角色账号可留空警号,但同样需要身份证号或手机号才能登录。
- 身份证号、手机号在系统中唯一,避免一个登录标识对应多个用户;导入和后台新增/编辑都会校验。
- 身份证号、手机号在系统中唯一,避免一个登录标识对应多个用户;后台手工新增/编辑时可选,批量导入时两者必填,导入和手工操作都会校验格式与重复。
- 不满足身份证号或手机号格式的字符串一律按警号处理。因此,如果某个警号本身恰好是 18 位身份证格式或 11 位手机号格式,登录时会被当作身份证号/手机号查询,应避免使用这类警号。
- 子系统的本地用户匹配使用 ID Token / UserInfo 中的 `police_number`、`id_card_number`、`phone_number`:三个标识中任意一个非空且与本地记录一致即可放行;门户登录使用哪种标识不影响这三个 claim。
- `admin`、`demo` 不能进入任何子系统:门户不会为它们签发警号、身份证号、手机号 claims,子系统本地校验会像普通未开通账号一样拒绝;子系统也不得用用户名或 `roles` 给它们开例外。
- `admin`、`demo` 不能进入任何子系统:门户在 OAuth 授权端点直接返回 403,不会签发授权码,也不会签发警号、身份证号、手机号 claims;子系统也不得用用户名或 `roles` 给它们开例外。
- 普通账号还必须先把应用添加到自己的门户;未添加或应用已下架时,授权端点同样返回 403。
> 升级提示:V12 迁移会新增 `id_card_number` 字段,并为 `id_card_number`、`phone` 建立唯一索引。旧库如果已有重复手机号,需要先清理重复数据再启动,否则 Flyway 迁移会失败;历史手机号若不是 11 位中国大陆手机号,仍会保留,但不能用于手机号登录,后续编辑或导入时会按新格式校验。V13 会清理升级前已保存的 `admin` / `demo` OAuth2 授权记录;当前版本它们仍可完成授权,但不会获得三个身份 claims,子系统会按普通未开通账号提示。普通用户升级后也建议重新登录一次,以获取身份证号、手机号 claims。
> 升级提示:V12 迁移会新增 `id_card_number` 字段,并为 `id_card_number`、`phone` 建立唯一索引。旧库如果已有重复手机号,需要先清理重复数据再启动,否则 Flyway 迁移会失败;历史手机号若不是 11 位中国大陆手机号,仍会保留,但不能用于手机号登录,后续编辑或导入时会按新格式校验。V13 会清理升级前已保存的 `admin` / `demo` OAuth2 授权记录;当前版本它们在授权端点直接被拒绝,不再签发授权码。普通用户升级后也建议重新登录一次,以获取身份证号、手机号 claims。
## 接入自己的子系统
1. 用管理员登录门户 → 管理后台 → 市场管理 → 新增应用
1. 用管理员登录门户 → 管理后台 → 智能体广场管理 → 新增应用
2. 填写访问地址;回调地址留空时默认是 `{访问地址}/sso/callback`
3. 保存后可在“接入信息”中随时查看并复制 `client_id` 和 `client_secret`(密钥加密存储,仅管理员可见,查看会写审计日志);点击“编辑”可修改接入标识、密钥和回调地址,点击保存时弹窗验证当前管理员密码
4. 子系统按 [docs/接入指南.md](docs/接入指南.md) 接入:文档涵盖 PHP、Python、Java(Starter / Spring Security OIDC)和自研客户端,并说明三个身份标识读取与本地用户校验。
......@@ -235,10 +241,11 @@ client_secret 始终只在服务端使用,浏览器全程接触不到。
### 子系统身份识别规则
- 门户登录:`admin`、`demo` 使用用户名;其他账号可使用警号、身份证号或手机号(详见“门户登录标识规则”)。
- `admin`、`demo` 禁止进入子系统:门户不签发三个身份 claims,子系统本地校验会显示与普通未开通账号一致的提示;V13 清理升级前的授权记录。
- 其他账号:子系统必须使用 ID Token / UserInfo 中的 `police_number`、`id_card_number`、`phone_number` 匹配本地用户,不能使用 `preferred_username`、`sub`、`roles` 或显示名作为授权键,也没有管理员绕过。
- `admin`、`demo` 禁止进入子系统:门户在授权端点直接返回 403,不会签发授权码或身份 claims;V13 清理升级前的授权记录。
- 其他账号:必须先把应用添加到个人门户;子系统必须使用 ID Token / UserInfo 中的 `police_number`、`id_card_number`、`phone_number` 匹配本地用户,不能使用 `preferred_username`、`sub`、`roles` 或显示名作为授权键,也没有管理员绕过。
- 三个标识中任意一个与本地用户一致且账号启用即可放行;三个标识都为空、账号不存在/停用,或同时命中多个本地用户时拒绝访问。
- 警号、身份证号、手机号属于敏感身份信息,已作为 JWT claims 提供给子系统;客户端和子系统不得把 ID Token 暴露给不可信前端或写入日志。
- Starter 默认每 60 秒调用 UserInfo 重校验会话(`session-revalidate-interval`)。门户用户被停用/删除、角色变化或应用被移除后,子系统会在一个校验周期内结束本地会话;Access Token 过期时用 Refresh Token 自动续期,续期失败即退出。
关键约束:认证中心登记的回调地址必须与子系统实际回调**完全一致**(协议、域名、端口、路径都不能差)。
......@@ -247,18 +254,23 @@ client_secret 始终只在服务端使用,浏览器全程接触不到。
- 登录失败按“登录标识+IP”5 次、同一 IP 20 次、15 分钟窗口限流;触发后登录页提示“登录尝试过于频繁”,计数器存在 Redis
- 采用登录标识+IP 而不是全局账号锁定,避免攻击者用错误密码把他人账号整体锁死;如仍有分布式暴力破解风险,建议叠加验证码或 MFA
- 成功登录会清除本次输入标识+IP 和实际账号+IP 的失败计数;同一 IP 的计数保留到窗口结束,防止在多个账号之间轮换尝试
- 审计事件覆盖:登录成功/失败/限流、退出登录、用户新增/修改/重置密码/删除、应用新增/修改/删除/重置密钥/查看密钥、修改接入信息、用户添加/移除应用
- 登录失败/限流审计中的身份证号、手机号只保留前后部分用于定位,其余脱敏;成功登录记录实际用户名
- 账号密码和数字证书登录成功时,都会把来源 IP 与登录方式写入会话;之后每个请求都会校验当前 IP。IP 发生变化时,立即撤销当前会话、该用户全部门户会话及全部 OAuth 授权,并记录“登录IP变更”审计;子系统会在下一次 UserInfo 重校验(默认 60 秒)时退出
- 审计事件覆盖:登录成功/失败/限流、登录IP变更、退出登录、用户新增/修改/重置密码/删除、应用新增/修改/删除/重置密钥/查看密钥、修改接入信息、用户添加/移除应用
- 登录审计会记录登录方式(账号密码/数字证书)与来源 IP;登录失败/限流审计中的身份证号、手机号只保留前后部分用于定位,其余脱敏;成功登录记录实际用户名
- 管理后台 → 审计日志,支持事件类型、关键字、时间范围筛选;详情不会记录密码、客户端密钥或令牌
- 用户角色变更、停用、删除、重置密码、修改登录标识或退出登录时,立即撤销其 Redis 会话与 OAuth 授权;应用下架或用户移除应用时,撤销对应授权
- 启动日志只提示已创建默认账号,不打印任何默认密码、客户端密钥或用户口令
- 审计表 `audit_log` 只提供写入和查询,不提供删除接口;生产环境需按合规要求定期归档
## 批量导入用户
- 管理后台 → 用户管理 → 批量导入;先下载模板,按表头填写。用户名、密码、姓名为必填列,警号(普通用户)、身份证号、邮箱、手机号、角色、状态等按模板说明填写
- 仅接受 `.xlsx`,文件不超过 5MB,单次最多 200 行;文件只在内存中解析,不写入磁盘
- 管理后台 → 用户管理 → 批量导入;先下载模板,按表头填写。用户名、密码、姓名、身份证号、手机号为必填列,警号(普通用户)、邮箱、角色、状态等按模板说明填写
- 仅接受 `.xlsx`,文件不超过 5MB,单次最多 5000 行;文件只在内存中解析,不写入磁盘
- 角色支持 `ADMIN` / `USER` 或“管理员” / “普通用户”;状态支持 `1` / `0` 或“启用” / “停用”,留空默认启用
- 每行独立校验:用户名格式、用户名重复、密码 8-64 位、邮箱格式、身份证号格式与重复、手机号格式与重复等;失败行返回行号和原因,其他行继续导入
- Excel 公式单元格会被拒绝;密码、身份证号和手机号建议设置为文本格式,避免 Excel 自动转换
- 每行独立校验:用户名格式、用户名重复、密码 8-64 位、邮箱格式、身份证号必填/格式/重复、手机号必填/格式/重复等;失败行返回行号和原因,其他行继续导入
- 模板已将用户名、密码、警号、身份证号、手机号列预设为文本格式;这些列若以数值格式提交会被拒绝,避免身份证号丢精度或警号丢失前导零
- 新导入账号首次登录必须修改初始密码,且新密码不能与原密码相同;修改前只能访问改密页
- 导入后 30 天内从未登录的账号自动停用;登录过之后按所有账号的 90 天规则处理。`admin` 不受自动停用规则影响。每日 03:17 执行检查,自动停用会撤销会话和 OAuth 授权并写入审计日志
- 批量删除在用户列表中勾选(不能删除自己),会同时清理被删用户的应用选择和个人布局
- 批量导入和批量删除都会写入审计日志
......@@ -271,19 +283,19 @@ client_secret 始终只在服务端使用,浏览器全程接触不到。
- MySQL 使用独立账号并限制来源;Redis 开启密码/ACL,不要暴露到公网
- 备份 `sso_jwk` 表:私钥丢失会导致所有已签发令牌失效;建议对私钥列做静态加密或使用 KMS
- 按实际业务调整登录限流阈值;多实例部署时共享 Redis 即可统一计数
- 反向代理后如需按真实客户端 IP 限流和审计,确认代理会覆盖 `X-Forwarded-For` 后再设置 `ZHIHUI_TRUST_FORWARDED_HEADERS=true`
- 反向代理后如需按真实客户端 IP 限流、审计和 IP 绑定,确认代理会覆盖 `X-Forwarded-For` 后再设置 `ZHIHUI_TRUST_FORWARDED_HEADERS=true`;未开启时不会采信转发头,客户端伪造 `X-Forwarded-For` 不会影响登录 IP
- 审计日志会持续增长,按合规要求制定归档/清理周期;审计接口仅管理员可访问,详情不记录密码和客户端密钥
- 定期轮换客户端密钥;离职人员及时停用账号
- 多实例部署时保持 `ZHIHUI_ISSUER` 一致,并共享 MySQL/Redis
## 已知限制与后续方向
- **单点退出范围**:退出门户会结束统一认证会话;各子系统本地会话仍保留到过期。若需要“一处退出、处处退出”,可增加 OIDC Back-Channel Logout 或子系统定期校验会话。
- **单点退出范围**:退出门户会结束统一认证会话并撤销 OAuth 授权;已接入 Starter 的子系统会在下一次会话重校验(默认 60 秒)时结束本地会话。若需要即时全局退出,可进一步接入 OIDC Back-Channel Logout。
- **权限模型**:目前只有 `ADMIN` / `USER` 两种角色,尚未实现菜单级/数据级 RBAC、组织架构。
- **浏览器纯前端接入**:当前客户端都是带密钥的机密客户端;Vue/React 纯 SPA 应使用 BFF 模式或由后端接入,暂不支持无密钥的公共客户端。
- **登录安全**:未内置验证码、MFA、AD/LDAP/企业微信等身份源;登录失败限流已内置,默认按登录标识+IP 和 IP 两个维度计数。
- **审计留存**:审计日志当前存 MySQL,未做 WORM/外部 SIEM 对接,生产环境需按合规要求归档。
- **批量导入**:当前为同步执行,单次最多 200 行;更大规模的数据迁移建议增加异步任务和进度查询。
- **批量导入**:当前为同步执行,单次最多 5000 行;更大规模的数据迁移建议增加异步任务和进度查询。
- **前端体积**:Element Plus 为全量引入,构建产物约 1.1 MB(gzip 约 380 KB),后续可按需引入减包。
- **认证中心拆分**:目前与门户同进程;流量增大后可拆成独立服务,OIDC 端点无需变化。
......@@ -329,10 +341,16 @@ CSRF 校验失败。正常浏览器表单不会出现;用 curl 测试时需要
- 子系统与认证中心不在同一浏览器上下文(例如一个 HTTPS、一个 HTTP)
- 服务器时间偏差过大导致 `exp` 校验失败
**admin/demo 点击应用提示“当前账号未开通本系统”**
这是预期行为,和普通账号没有本地权限时一致。`admin`、`demo` 是门户管理账号,门户不会为它们签发警号、身份证号、手机号 claims,子系统的本地用户校验会拒绝。请使用普通账号的对应标识登录后再进入;子系统也不应以用户名或 `ADMIN` 角色给这两个账号开例外。
**admin/demo 点击应用显示“无法访问应用”(403)**
这是预期行为。`admin`、`demo` 是门户管理账号,门户在 OAuth 授权端点直接拒绝,不会签发授权码或身份 claims。请使用普通账号并先把应用添加到个人门户;子系统也不应以用户名或 `ADMIN` 角色给这两个账号开例外。
**普通账号提示“当前账号未开通本系统”**
**普通账号点击应用显示“无法访问应用”(403)**
- 该应用还没有添加到当前账号的个人门户:到“智能体广场”添加后再进入
- 应用已被管理员下架(状态不是启用)
- 授权请求缺少 `client_id`,或 `client_id` 与已上架应用不匹配
- 门户账号已被停用或删除,请重新登录或联系管理员
**提示“当前账号未开通本系统”**
- 子系统本地用户表没有与该账号任一标识匹配的记录
- 本地账号已停用
- 三个标识同时命中多个本地用户,系统拒绝歧义匹配
......
<?xml version="1.0" encoding="utf-8"?><authinfo><liblist><lib type="CSP" version="1.0" dllname="TWljcm9zb2Z0IEVuaGFuY2VkIENyeXB0b2dyYXBoaWMgUHJvdmlkZXIgdjEuMA=="><algid val="SHA1" sm2_hashalg="SHA1" /></lib><lib type="CSP" version="1.0" dllname="TWljcm9zb2Z0IFN0cm9uZyBDcnlwdG9ncmFwaGljIFByb3ZpZGVy"><algid val="SHA1" sm2_hashalg="SHA1" /></lib><lib type="CSP" version="1.0.0.1" dllname="ZVNhZmUgQ3J5cHRvZ3JhcGhpYyBTZXJ2aWNlIFByb3ZpZGVyIHYxLjA="><algid val="SHA1" sm2_hashalg="SHA1" /></lib><lib type="SKF" version="1.0" dllname="L29wdC9HQVNTL2xpYlNLRi9saWJTS0ZfR0FfYXBpLnNv"><algid val="SHA1" sm2_hashalg="SM3" /></lib><lib type="SKF" version="1.7.8.3" dllname="R0FLRVlfU0tGLmRsbA=="><algid val="SHA1" sm2_hashalg="SM3" /></lib><lib type="SKF" version="1.0" dllname="L29wdC9HQVNTL2xpYlNLRi9saWJTS0ZfR0FGX2FwaS5zbw=="><algid val="SHA1" sm2_hashalg="SM3" /></lib><lib type="SKF" version="1.8.10" dllname="R0FGS0VZX1NLRi5kbGw="><algid val="SHA1" sm2_hashalg="SM3" /></lib></liblist></authinfo>
\ No newline at end of file
......@@ -14,9 +14,9 @@
无论使用哪种方式,都先在门户后台登记应用。
> 统一认证只负责确认身份。`admin`、`demo` 是门户管理账号,门户不会为它们签发警号、身份证号、手机号 claims,子系统按普通未开通账号拒绝即可;其他账号由子系统使用 `police_number`、`id_card_number`、`phone_number` 三个 claim 中的任一标识匹配本地用户。不要用 `preferred_username`、`sub`、显示名或 `roles` 作为授权依据。用户是否已在子系统开通、是否启用、能做哪些业务操作,仍由子系统查询自己的用户表或权限系统判断。门户和 Starter 不提供访问预检接口,也不接收子系统的无权限回调。
> 统一认证只负责确认身份。`admin`、`demo` 是门户管理账号,门户授权端点会直接拒绝它们;其他账号必须先把应用添加到个人门户,且应用处于上架状态,才能发起 OAuth 授权。门户不会为 `admin`、`demo` 签发警号、身份证号、手机号 claims。其他账号由子系统使用 `police_number`、`id_card_number`、`phone_number` 三个 claim 中的任一标识匹配本地用户。不要用 `preferred_username`、`sub`、显示名或 `roles` 作为授权依据。用户是否已在子系统开通、是否启用、能做哪些业务操作,仍由子系统查询自己的用户表或权限系统判断。门户和 Starter 不提供访问预检接口,也不接收子系统的无权限回调。
>
> 三个标识会写入 ID Token / UserInfo(`profile` scope)。任意一个与本地用户一致且账号启用即可放行;三者都为空、无法匹配或同时命中多个本地用户时拒绝访问。
> 三个标识会写入 ID Token / UserInfo(`profile` scope)。任意一个与本地用户一致且账号启用即可放行;三者都为空、无法匹配或同时命中多个本地用户时拒绝访问。Starter 默认每 60 秒用 UserInfo 重校验一次本地会话,失败会尝试刷新令牌,仍失败则重新登录。
---
......@@ -26,9 +26,9 @@
| 账号 | 门户登录方式 | 子系统访问规则 |
| --- | --- | --- |
| `admin` | 仅用户名 | 不签发三个身份 claims,由子系统按普通未开通账号拒绝 |
| `demo` | 仅用户名 | 不签发三个身份 claims,由子系统按普通未开通账号拒绝 |
| 其他账号(含其他 `ADMIN` 角色账号) | 警号 / 身份证号 / 手机号 | 按 `police_number`、`id_card_number`、`phone_number` 任一标识匹配本地用户,无管理员绕过 |
| `admin` | 仅用户名 | 门户授权端点直接拒绝,不签发授权码 |
| `demo` | 仅用户名 | 门户授权端点直接拒绝,不签发授权码 |
| 其他账号(含其他 `ADMIN` 角色账号) | 警号 / 身份证号 / 手机号 | 先将应用添加到个人门户,再按三个标识匹配本地用户,无管理员绕过 |
登录页只有一个“账号”输入框,按输入格式自动判断:
......@@ -48,17 +48,17 @@
- 三个 claim 需要 `profile` scope;Starter 默认已申请。子系统应取非空标识查询本地用户,三个标识都没有时直接拒绝。
- 登录页使用的是哪种标识,不影响三个 claim 的内容;只要用户填写了对应字段,claims 就会带上。
- `admin`、`demo` 不能通过任何 `ADMIN` 角色绕过:门户不会为它们签发身份 claims,子系统按普通未开通账号处理;其他 `ADMIN` 角色账号也必须按三个标识匹配本地用户。
- `admin`、`demo` 不能通过任何 `ADMIN` 角色绕过:门户授权端点直接拒绝它们;其他 `ADMIN` 角色账号也必须先把应用添加到个人门户,并按三个标识匹配本地用户。
- 如果三个标识同时命中不同本地用户,属于身份歧义,必须拒绝访问。
- 如果某个警号本身恰好是 18 位身份证格式或 11 位手机号格式,登录时会被当作身份证号/手机号查询,应避免使用这类警号。
- 维护路径:管理后台 → 用户管理 → 编辑;Excel 导入模板已包含“身份证号”“手机号”列。
- 升级提示:V13 会清理升级前已保存的 `admin` / `demo` OAuth2 授权;当前版本它们仍可完成授权,但不会获得三个身份 claims,子系统会按普通未开通账号拒绝。普通用户从旧版本升级后建议重新登录一次,让新 ID Token 带上 `id_card_number`、`phone_number`。
- 维护路径:管理后台 → 用户管理 → 编辑;Excel 导入模板已包含“身份证号”“手机号”列,批量导入时两列均为必填。
- 升级提示:V13 会清理升级前已保存的 `admin` / `demo` OAuth2 授权;当前版本门户授权端点直接拒绝这两个账号,不会签发授权码。普通用户从旧版本升级后建议重新登录一次,让新 ID Token 带上 `id_card_number`、`phone_number`。
---
## 0. 在门户创建应用
1. 管理员登录门户 → 管理后台 → 市场管理 → 新增应用
1. 管理员登录门户 → 管理后台 → 智能体广场管理 → 新增应用
2. 填写:
- 应用名称、应用编码(小写字母/数字/中划线)
- 访问地址:用户点击应用时打开的地址
......@@ -93,13 +93,15 @@
1. 子系统生成并保存 `state`、`nonce`(服务端 OIDC 库通常自动处理)
2. 浏览器跳转 `{issuer}/oauth2/authorize`,携带 `response_type=code`、`client_id`、`redirect_uri`、`scope`、`state`、`nonce`
3. 门户已有登录态时直接返回授权码;否则展示登录页(账号输入框支持警号、身份证号或手机号;`admin` / `demo` 使用用户名)
3. 门户已有登录态,且应用已上架、当前用户已把应用添加到个人门户时直接返回授权码;否则展示登录页,或直接返回 403(`admin` / `demo`、未添加的应用、已下架应用)
4. 回调 `redirect_uri?code=...&state=...`
5. 子系统校验 `state`,在**服务端**用授权码 + `client_id` + `client_secret` 调用 `/oauth2/token`
6. 校验 ID Token:JWKS 签名、`iss`、`aud`、`exp`、`nonce`
7. 从已校验的 claims 中读取 `police_number`、`id_card_number`、`phone_number`;`preferred_username`、`name` 等只用于展示
8. 用三个标识中的任一非空值查询本地用户表,校验账号存在且启用;三者都未提供、账号不存在/停用,或同时命中多个本地用户时拒绝访问。`admin` / `demo` 没有身份 claims,会在这里按普通未开通账号被拒绝,不需要也不应为它们开例外
9. 校验通过后写入子系统自己的 Session;未通过则返回 403 或跳转子系统提示页
8. 用三个标识中的任一非空值查询本地用户表,校验账号存在且启用;三者都未提供、账号不存在/停用,或同时命中多个本地用户时拒绝访问。`admin` / `demo` 已在门户授权阶段被拒绝,子系统不应为它们开例外
9. 校验通过后写入子系统自己的 Session;未通过则返回 403 或跳转子系统提示页。使用 Java Starter 时会默认每 60 秒通过 UserInfo 重新校验会话
> 门户登录会话会绑定登录时的客户端 IP;账号密码和数字证书登录都会记录登录方式与来源 IP。IP 变化时门户会撤销该用户全部门户会话和 OAuth 授权,子系统在下一次 UserInfo 重校验时会退出。接入方无需自行实现 IP 绑定,也不要把门户登录 IP 当作业务权限依据。
`client_secret` 只出现在服务端,浏览器全程接触不到。纯前端 SPA 无法安全保存它,应使用 BFF 或由后端接入。
......@@ -273,7 +275,7 @@ exit;
- 登录成功后调用 `session_regenerate_id(true)`,避免会话固定
- 不要只解析 ID Token 而不校验签名、`iss`、`aud`、`exp`
- SSO 只完成身份认证,登录成功后仍需查询本地用户表;不要自动放行所有门户用户
- 当前单点退出只结束门户认证会话;PHP 本地 Session 需要自己销毁
- 门户退出会结束认证会话并撤销 OAuth 授权;非 Starter 接入(如 PHP)的本地 Session 需要自己销毁,并在后续请求中重新校验
---
......@@ -453,6 +455,8 @@ zhihui:
client-secret: ${ZHIHUI_CLIENT_SECRET}
redirect-uri: http://localhost:8081/sso/callback
post-logout-redirect-uri: http://localhost:8081/
# 已登录会话通过 UserInfo 重新校验的间隔;0 表示关闭
session-revalidate-interval: 60s
exclude-patterns:
- /css/**
- /js/**
......@@ -468,6 +472,7 @@ zhihui:
| `redirect-uri` | 是 | — | 必须与后台回调地址完全一致 |
| `post-logout-redirect-uri` | 否 | — | 退出后回到的地址,需在后台登记 |
| `scopes` | 否 | `openid profile email` | 申请的 scope |
| `session-revalidate-interval` | 否 | `60s` | 已登录会话通过 UserInfo 重校验的间隔;设为 `0` 关闭 |
| `exclude-patterns` | 否 | 常见静态资源 | 不拦截的 Ant 风格路径 |
| `login-path` | 否 | `/sso/login` | 手动触发登录 |
| `callback-path` | 否 | `/sso/callback` | 回调路径 |
......@@ -542,11 +547,11 @@ if (!localUserService.existsActiveByIdentifiers(
必须用 `ssoUser.getPoliceNumber()`、`ssoUser.getIdCardNumber()`、`ssoUser.getPhoneNumber()` 中的任一非空标识关联本地用户表;`username`、`subject`、`roles` 只用于展示,不能作为授权依据。三个标识都为空、本地用户不存在/停用,或同时命中多个本地用户时都要拒绝访问。是否允许首次登录自动建号由子系统决定,但不能因为 SSO 成功就跳过本地校验。
`admin`、`demo` 不会获得警号、身份证号、手机号 claims,子系统按普通未开通账号拒绝即可;不需要为它们保留管理员例外。
`admin`、`demo` 由门户授权端点直接拒绝,不会获得授权码或任何身份 claims;如果旧会话或绕过门户的请求仍到达子系统,子系统也应按普通未开通账号处理,不要为它们保留管理员例外。
门户签发的 ID Token 会带 `police_number`、`id_card_number`、`phone_number`(Starter 默认申请 `openid profile email`,三个 claim 均随 `profile` scope 返回);如果改用标准 OIDC 客户端并通过 UserInfo 获取,需要保留 `profile` scope。
示例子系统已经按这个思路实现:普通用户的本地账号维护在 `zhihui-demo-app/src/main/resources/application.yml` 的 `zhihui.demo.local-users`,账号可配置警号、身份证号、手机号,登录名只用于展示;`admin`、`demo` 不在本地清单中,且不会拿到身份 claims,因此会显示与普通未开通账号一致的提示。
示例子系统已经按这个思路实现:普通用户的本地账号维护在 `zhihui-demo-app/src/main/resources/application.yml` 的 `zhihui.demo.local-users`,账号可配置警号、身份证号、手机号,登录名只用于展示;`admin`、`demo` 不在本地清单中,且门户不会为其签发授权码,因此不会进入子系统。
```yaml
zhihui:
......@@ -563,6 +568,16 @@ zhihui:
普通用户的校验规则是“三个标识中任一匹配 + 账号启用”;不要求登录名一致,也不给 `ADMIN` 角色开例外。三个标识都为空、未开通/停用,或同时命中多个本地用户时跳转子系统的 `/no-local-user` 页面。生产系统应把这份读取逻辑替换为查询自己的用户表或权限系统。
#### 会话重校验与撤销
Starter 默认每 `60s` 用保存的 Access Token 调用门户 `/userinfo` 重校验一次已有会话:
- 返回 `2xx`:继续使用本地 Session。
- 返回 `401/403`:尝试用 Refresh Token 刷新;刷新成功后更新 `SsoUser`、Access Token 和 ID Token。
- 刷新失败(用户在门户被停用、删除、修改身份标识,或门户退出/移除应用):销毁子系统 Session 并跳回门户登录。
这样门户的账号变更和授权撤销不必等到子系统 8 小时会话自然过期。可通过 `session-revalidate-interval` 调整间隔;设为 `0` 可关闭。标准 OIDC 客户端没有 Starter 的自动重校验时,应自行实现 UserInfo/Token 校验或 Back-Channel Logout。
#### 门户跳转
门户点击应用时直接在新标签页打开应用配置的“访问地址”,不再经过门户预检接口。子系统若拒绝访问,请在子系统自己的页面和审计日志中提示、记录;门户不会收到拒绝回调。
......@@ -579,6 +594,7 @@ zhihui:
- 静态资源和 `exclude-patterns` 不拦截
- `/sso/login?redirect=/目标路径` 可手动触发登录
- 已登录但本地用户不存在或被停用:由子系统自己的用户校验逻辑返回 403 或跳转提示页
- 门户停用/删除用户、修改身份标识、移除应用或退出:Starter 会在下一次 UserInfo 重校验时销毁本地会话
- 多实例部署时,`SsoUser` 已实现 `Serializable`;建议配合 Spring Session(Redis/JDBC),否则需要会话粘滞
#### 与现有 Spring Security 共存
......@@ -661,7 +677,7 @@ public String index(@AuthenticationPrincipal OidcUser user) {
}
```
`user.getClaim("police_number")`、`user.getClaim("id_card_number")`、`user.getClaim("phone_number")` 来自门户签发的 ID Token。登录成功后必须用三个标识中的任一非空值查询本地用户表;三者都为空、账号不存在/停用,或同时命中多个本地用户时拒绝访问。`preferred_username`、`sub`、`roles` 只用于展示,不能作为授权依据;`admin`、`demo` 没有身份 claims,会被本地校验按普通未开通账号拒绝。
`user.getClaim("police_number")`、`user.getClaim("id_card_number")`、`user.getClaim("phone_number")` 来自门户签发的 ID Token。登录成功后必须用三个标识中的任一非空值查询本地用户表;三者都为空、账号不存在/停用,或同时命中多个本地用户时拒绝访问。`preferred_username`、`sub`、`roles` 只用于展示,不能作为授权依据;`admin`、`demo` 在门户授权端点即被拒绝,不会进入子系统。
退出可以使用 `OidcClientInitiatedLogoutSuccessHandler`,把 `{baseUrl}/` 作为登出回跳地址,并确保已在后台登记。
......@@ -694,7 +710,7 @@ Authorization: Basic base64(urlencode(client_id):urlencode(client_secret))
- `nonce` 与第一步一致
7. 从已验证的 claims 中读取 `police_number`、`id_card_number`、`phone_number`;`preferred_username`、`uid` 只用于展示
8. 用三个标识中的任一非空值查询本地用户表,确认账号存在且启用;三者都为空、未命中或同时命中多个本地用户时拒绝。`admin` / `demo` 没有身份 claims,会按普通未开通账号被拒绝
8. 用三个标识中的任一非空值查询本地用户表,确认账号存在且启用;三者都为空、未命中或同时命中多个本地用户时拒绝。`admin` / `demo` 在门户授权端点即被拒绝,不会进入子系统
9. 校验通过后建立本地会话,`state` / `nonce` / `code_verifier` 用完即删
不要:
......@@ -728,7 +744,7 @@ ID Token / UserInfo 可用字段:
`police_number`、`id_card_number`、`phone_number` 都是自定义 claim,Starter 会从 ID Token 读取并放入 `SsoUser`;标准 OIDC 客户端可从 ID Token 或 UserInfo 读取。UserInfo 返回这三个 claim 需要申请 `profile` scope。
> 这三个 claim 属于敏感身份信息,会随 ID Token / Access Token 下发给客户端。子系统不得把它们写入普通日志、URL 或前端可公开访问的存储;`admin`、`demo` 拿不到这些 claim,子系统会按普通未开通账号拒绝。
> 这三个 claim 属于敏感身份信息,会随 ID Token / Access Token 下发给客户端。子系统不得把它们写入普通日志、URL 或前端可公开访问的存储;`admin`、`demo` 拿不到这些 claim,且门户授权端点会直接拒绝它们。
---
......@@ -752,10 +768,9 @@ ID Token / UserInfo 可用字段:
当前实现 **RP-Initiated Logout**:
- 子系统退出:结束子系统本地会话 + 门户认证会话
- 门户退出:结束门户认证会话
- 其他子系统的本地会话不会立即失效,会保留到各自超时
如果需要“一处退出、处处退出”,需要增加 OIDC Back-Channel Logout,或在子系统采用短期会话/定期校验。
- 门户退出:结束门户认证会话,并撤销该用户已有的 OAuth 授权
- Java Starter 默认每 60 秒通过 UserInfo 重校验;门户用户停用、删除、身份标识变更、移除应用或退出后,子系统会在下一次重校验时结束本地会话
- 已有自包含 JWT Access Token 在过期前仍可能被其他资源服务器接受;标准 OIDC 客户端应自行实现 UserInfo 校验、短期 Token 或 Back-Channel Logout
---
......@@ -769,9 +784,11 @@ ID Token / UserInfo 可用字段:
- [ ] 服务端时间已同步(NTP)
- [ ] `client_secret` 只在服务端,通过环境变量/密钥管理注入
- [ ] 子系统会话 Cookie 设置 HttpOnly、SameSite=Lax 或更严格
- [ ] 应用处于上架状态,且业务用户已在个人门户中添加该应用
- [ ] 普通用户已使用 `police_number`、`id_card_number`、`phone_number` 中的任一标识查询本地用户表;三者都为空、账号不存在/停用或多用户命中时拒绝访问
- [ ] 子系统没有使用用户名、`sub`、`roles` 或显示名作为授权依据
- [ ] 已确认 `admin` / `demo` 被门户拒绝,且子系统没有为它们保留管理员绕过
- [ ] 已确认 `admin` / `demo` 被门户授权端点拒绝,且子系统没有为它们保留管理员绕过
- [ ] Java Starter 的 `session-revalidate-interval` 已按业务安全要求设置(默认 60 秒)
- [ ] 已确认单点退出范围符合预期
---
......@@ -790,7 +807,9 @@ ID Token / UserInfo 可用字段:
| 身份证号/手机号无法登录 | 对应用户未填写该字段、格式不是 18 位身份证号或 11 位手机号,或账号已停用 |
| 子系统 401 但浏览器有登录态 | Cookie 不在同一浏览器上下文,或 Starter 的 `exclude-patterns` 配错 |
| 登录成功但显示“未开通本系统” | 本地用户表没有匹配该账号任一标识的记录、账号已停用、同时命中多个本地用户,或 ID Token 缺少三个身份 claims |
| OAuth 授权返回 403 | 应用未上架、当前用户未把应用添加到个人门户,或账号是 `admin` / `demo` |
| 子系统使用中突然回到登录页 | 门户账号被停用/删除、身份标识变更、应用被移除/下架,或门户会话已退出;Starter 的 UserInfo 重校验失败后会重新登录 |
| 旧版本升级后看不到身份证号/手机号 claims | 旧授权记录或旧 Token 尚未刷新;退出子系统并重新登录,或等待旧 Token 过期 |
| admin/demo 无法进入子系统 | 预期行为:门户不签发三个身份 claims,子系统按普通未开通账号提示无权限 |
| admin/demo 无法进入子系统 | 预期行为:门户授权端点直接返回 403,不会签发授权码 |
更完整的部署、安全与故障排查见项目根目录 [README.md](../README.md)。
# ============================================================
# 智汇平台 .env(含数字证书登录配置)
# 复制为 .env 后按现场情况修改
# ============================================================
# ---------- MySQL ----------
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=zhihui
DB_USER=zhihui
DB_PASSWORD=CHANGE_ME
# ---------- Redis ----------
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DATABASE=3
# ---------- 门户地址 ----------
# 必须与浏览器地址栏完全一致;HTTPS 时写成 https://...
SERVER_PORT=8092
ZHIHUI_ISSUER=http://localhost:8092
# ---------- 初始管理员和密钥 ----------
# 仅首次创建 admin 时生效;已有 admin 时不会覆盖
ZHIHUI_ADMIN_PASSWORD=Admin@123
# 生产环境必须改成独立强随机值;设置后不要再改,否则已保存的应用密钥无法解密
ZHIHUI_CLIENT_SECRET_KEY=CHANGE_ME_RANDOM_32_BYTES
# ---------- 示例子系统 ----------
ZHIHUI_DEMO_ENABLED=true
ZHIHUI_DEMO_APP_URL=http://localhost:8081
ZHIHUI_DEMO_CLIENT_ID=demo-subsystem
ZHIHUI_DEMO_CLIENT_SECRET=demo-secret-2026
ZHIHUI_DEMO2_ENABLED=true
ZHIHUI_DEMO2_APP_URL=http://localhost:8082
ZHIHUI_DEMO2_CLIENT_ID=demo-subsystem-2
ZHIHUI_DEMO2_CLIENT_SECRET=demo-secret-2026-2
# ---------- 安全 ----------
ZHIHUI_COOKIE_SECURE=false
ZHIHUI_TRUST_FORWARDED_HEADERS=false
ZHIHUI_LOGIN_RATE_LIMIT_ENABLED=true
ZHIHUI_LOGIN_MAX_FAILURES_PER_USER_IP=5
ZHIHUI_LOGIN_MAX_FAILURES_PER_IP=20
ZHIHUI_LOGIN_FAILURE_WINDOW=15m
# ============================================================
# 数字证书登录(吉大正元网关旁路模式)
# ============================================================
ZHIHUI_CERT_AUTH_ENABLED=true
# 生产网关地址,通常以 /MessageService 结尾
# 示例:https://10.0.0.10:8443/MessageService
ZHIHUI_CERT_GATEWAY_URL=https://CHANGE_ME_GATEWAY_HOST:PORT/MessageService
# 网关管理员分配的应用标识
ZHIHUI_CERT_APP_ID=CHANGE_ME_APP_ID
# 开发商 demo 的网关参数(来自 bsdemo_v2.1/demo/WEB-INF/jitgwmessage.properties):
# ZHIHUI_CERT_GATEWAY_URL=http://192.168.0.110:6180/MessageService
# ZHIHUI_CERT_APP_ID=testApp
# 本机 Debugger 测试:
# ZHIHUI_CERT_GATEWAY_URL=http://127.0.0.1:6180/MessageService
# ZHIHUI_CERT_APP_ID=testApp
# 初始化参数二选一(必须配置其中一项,否则登录页不会显示证书入口):
# 已附带从开发商 demo login.jsp 提取的 XML:
ZHIHUI_CERT_INIT_PARAM_FILE=cert/initParam-demo.xml
# 也可以直接写在 .env(不推荐,XML 含引号,容易转义出错):
# ZHIHUI_CERT_INIT_PARAM=...
# 生产环境请用网关“认证管理 -> Key类型管理”导出的参数替换该文件内容
# UKey 是否需要输入 PIN
ZHIHUI_CERT_PIN_CODE=false
ZHIHUI_CERT_ACCESS_CONTROL=false
# 测试环境使用 http 网关时必须改为 true;生产使用 https 时保持 false
ZHIHUI_CERT_ALLOW_INSECURE_HTTP=false
# 网关属性模式,默认 portion
ZHIHUI_CERT_ATTRIBUTES_MODE=portion
# 用户证书 SubjectDN 中身份证号所在字段,默认 CN
ZHIHUI_CERT_SUBJECT_DN_ID_ATTRIBUTE=CN
# 网关调用超时和证书挑战有效期
ZHIHUI_CERT_CONNECT_TIMEOUT=5s
ZHIHUI_CERT_REQUEST_TIMEOUT=15s
ZHIHUI_CERT_CHALLENGE_TTL=2m
ZHIHUI_CERT_MAX_RESPONSE_BYTES=262144
ZHIHUI_CERT_MAX_SIGNED_DATA_BYTES=524288
# 网关 HTTPS 使用内部 CA 时配置根证书(PKCS12 或 JKS)
# 如果网关证书由公共 CA 签发,保持注释即可
# ZHIHUI_CERT_TRUST_STORE=D:/zhihui-dev/cert/gateway-trust.p12
# ZHIHUI_CERT_TRUST_STORE_PASSWORD=CHANGE_ME
# ZHIHUI_CERT_TRUST_STORE_TYPE=PKCS12
# 可选:PNXClient 证书控件下载地址;
# 建议使用相对路径(如 /downloads/PNXClient.exe)或 https 地址
ZHIHUI_CERT_CLIENT_DOWNLOAD_URL=
# ============================================================
# 本机 Debugger 测试示例(仅测试环境使用,生产不要照搬)
# ============================================================
# ZHIHUI_CERT_GATEWAY_URL=http://127.0.0.1:6180/MessageService
# ZHIHUI_CERT_APP_ID=testApp
# ZHIHUI_CERT_ALLOW_INSECURE_HTTP=true
# ZHIHUI_CERT_INIT_PARAM=
# ZHIHUI_CERT_INIT_PARAM_FILE=C:/path/to/initParam.txt
# ZHIHUI_CERT_TRUST_STORE=
# ZHIHUI_CERT_TRUST_STORE_PASSWORD=
......@@ -16,7 +16,7 @@ DEMO_CALLBACK="${DEMO_URL}/sso/callback"
SMOKE_USER="${ZHIHUI_SMOKE_USER:-smoke_subsystem}"
SMOKE_PASSWORD="${ZHIHUI_SMOKE_PASSWORD:-Smoke@123}"
SMOKE_POLICE="${ZHIHUI_SMOKE_POLICE:-SMK660909}"
SMOKE_ID_CARD="${ZHIHUI_SMOKE_ID_CARD:-110101199001011234}"
SMOKE_ID_CARD="${ZHIHUI_SMOKE_ID_CARD:-110101199001019999}"
SMOKE_PHONE="${ZHIHUI_SMOKE_PHONE:-13800138000}"
TMP="$(mktemp -d)"
......@@ -104,24 +104,6 @@ exchange_code() {
-o "$output" || fail "令牌交换失败"
}
assert_no_identity_claims() {
local token_file="$1" label="$2"
python - "$token_file" "$label" <<'PY'
import base64, json, sys
data = json.load(open(sys.argv[1], encoding='utf-8'))
token = data.get('id_token')
if not token:
raise SystemExit('no id_token')
payload = token.split('.')[1]
payload += '=' * (-len(payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload))
for key in ('police_number', 'id_card_number', 'phone_number'):
if claims.get(key):
raise SystemExit(f'{sys.argv[2]} unexpectedly has {key}')
print('ok')
PY
}
echo "== 1. 健康检查 =="
curl -fsS --max-time 5 "$BASE_URL/actuator/health" >/dev/null || fail "门户未启动"
pass "门户可访问:$BASE_URL"
......@@ -131,45 +113,35 @@ login "$ADMIN_JAR" "$ADMIN_USER" "$ADMIN_PASSWORD"
[ "$(me_username "$ADMIN_JAR")" = "$ADMIN_USER" ] || fail "/api/me 返回异常"
pass "登录成功:$ADMIN_USER"
echo "== 3. admin 进入子系统由本地校验拒绝 =="
echo "== 3. admin 请求子系统授权由门户拒绝 =="
STATUS="$(authorize_status "$ADMIN_JAR" "$TMP/admin-authz.html")"
[ "$STATUS" = "302" ] || fail "admin 未获得授权跳转(HTTP $STATUS)"
LOCATION="$(grep -i '^location:' "$TMP/authz.headers" | sed 's/^[Ll]ocation: //' | tr -d '\r')"
CODE="$(printf '%s' "$LOCATION" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')"
[ -n "$CODE" ] || fail "admin 未取得授权码:$LOCATION"
exchange_code "$CODE" "$TMP/admin-token.json"
assert_no_identity_claims "$TMP/admin-token.json" "admin" >/dev/null \
|| fail "admin 的 ID Token 不应包含身份 claims"
[ "$STATUS" = "403" ] || fail "admin 未按预期被门户拒绝(HTTP $STATUS)"
pass "admin 请求 OAuth 授权被门户拒绝(403)"
if curl -s --max-time 3 -o /dev/null "$DEMO_URL/"; then
cp "$ADMIN_JAR" "$TMP/admin-demo-cookies"
FINAL_STATUS="$(curl -s -L --max-redirs 10 -b "$TMP/admin-demo-cookies" -c "$TMP/admin-demo-cookies" \
-H "Accept: text/html" -o "$TMP/admin-demo.html" -w '%{http_code}' "$DEMO_URL/")"
[ "$FINAL_STATUS" = "200" ] || fail "admin 访问示例子系统返回 HTTP $FINAL_STATUS"
grep -q "当前账号未开通本系统" "$TMP/admin-demo.html" \
|| fail "admin 未显示与普通账号一致的未开通提示"
pass "admin 进入子系统时显示普通账号同款无权限提示"
[ "$FINAL_STATUS" = "403" ] || fail "admin 访问示例子系统返回 HTTP $FINAL_STATUS"
grep -q "无法访问应用" "$TMP/admin-demo.html" \
|| fail "admin 未显示门户拒绝页面"
pass "admin 进入子系统时被门户拒绝"
else
echo " [SKIP] 示例子系统未启动($DEMO_URL),跳过 admin 页面校验"
fi
echo "== 4. demo 进入子系统由本地校验拒绝 =="
echo "== 4. demo 请求子系统授权由门户拒绝 =="
login "$TMP/demo.cookies" "$DEMO_USER" "$DEMO_PASSWORD"
STATUS="$(authorize_status "$TMP/demo.cookies" "$TMP/demo-authz.html")"
[ "$STATUS" = "302" ] || fail "demo 未获得授权跳转(HTTP $STATUS)"
LOCATION="$(grep -i '^location:' "$TMP/authz.headers" | sed 's/^[Ll]ocation: //' | tr -d '\r')"
CODE="$(printf '%s' "$LOCATION" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')"
[ -n "$CODE" ] || fail "demo 未取得授权码:$LOCATION"
exchange_code "$CODE" "$TMP/demo-token.json"
assert_no_identity_claims "$TMP/demo-token.json" "demo" >/dev/null \
|| fail "demo 的 ID Token 不应包含身份 claims"
[ "$STATUS" = "403" ] || fail "demo 未按预期被门户拒绝(HTTP $STATUS)"
pass "demo 请求 OAuth 授权被门户拒绝(403)"
if curl -s --max-time 3 -o /dev/null "$DEMO2_URL/"; then
cp "$TMP/demo.cookies" "$TMP/demo2-cookies"
FINAL_STATUS="$(curl -s -L --max-redirs 10 -b "$TMP/demo2-cookies" -c "$TMP/demo2-cookies" \
-H "Accept: text/html" -o "$TMP/demo-no-local.html" -w '%{http_code}' "$DEMO2_URL/")"
[ "$FINAL_STATUS" = "200" ] || fail "demo 访问第二个子系统返回 HTTP $FINAL_STATUS"
grep -q "当前账号未开通本系统" "$TMP/demo-no-local.html" \
|| fail "demo 未显示与普通账号一致的未开通提示"
pass "demo 进入子系统时显示普通账号同款无权限提示"
[ "$FINAL_STATUS" = "403" ] || fail "demo 访问第二个子系统返回 HTTP $FINAL_STATUS"
grep -q "无法访问应用" "$TMP/demo-no-local.html" \
|| fail "demo 未显示门户拒绝页面"
pass "demo 进入子系统时被门户拒绝"
else
echo " [SKIP] 第二个子系统未启动($DEMO2_URL),跳过 demo 页面校验"
fi
......@@ -215,6 +187,22 @@ login "$SMOKE_JAR" "$SMOKE_POLICE" "$SMOKE_PASSWORD"
[ "$(me_username "$SMOKE_JAR")" = "$SMOKE_USER" ] || fail "普通账号 /api/me 返回异常"
pass "普通账号可用警号登录"
echo "== 6.1 将示例应用添加到测试账号 =="
MARKET="$(curl -fsS -b "$SMOKE_JAR" "$BASE_URL/api/market")"
APP_IDS="$(printf '%s' "$MARKET" | python -c '
import json, sys
apps = json.load(sys.stdin)
print(" ".join(str(item["id"]) for item in apps if item.get("appCode") in ("demo", "demo2")))
')"
[ -n "$APP_IDS" ] || fail "智能体广场中没有示例应用"
SMOKE_CSRF="$(cookie_xsrf "$SMOKE_JAR")" || fail "测试账号缺少 XSRF token"
for app_id in $APP_IDS; do
STATUS="$(curl -s -b "$SMOKE_JAR" -c "$SMOKE_JAR" -o /dev/null -w '%{http_code}' \
-X POST "$BASE_URL/api/apps/$app_id/show" -H "X-XSRF-TOKEN: $SMOKE_CSRF")"
[ "$STATUS" = "200" ] || fail "添加示例应用失败(app=$app_id, HTTP $STATUS)"
done
pass "测试账号已添加示例应用到个人门户"
echo "== 7. 普通账号 OIDC 授权 =="
STATUS="$(authorize_status "$SMOKE_JAR" "$TMP/smoke-authz.html")"
[ "$STATUS" = "302" ] || fail "普通账号未获得授权跳转(HTTP $STATUS)"
......
-- ============================================================
-- 智汇平台 MySQL 测试数据包(完整表结构 + 初始数据)
-- 适用:MySQL 8.0 / 8.4,字符集 utf8mb4
--
-- 导入(目标库必须为空;把 zhihui 换成实际库名):
-- sudo mysql zhihui < scripts/zhihui-seed.sql
-- 库或账号不存在时,先执行 scripts/init-mysql.sql。
-- 本文件不含 CREATE DATABASE / USE,避免误导入到其他库。
--
-- 测试账号:
-- admin / Admin@123 系统管理员
-- demo / Demo@123 演示用户
-- test / Test@123 普通用户,警号 660909
-- sso_test / Sso@123 普通用户,警号 661282
-- cert_test / Cert@123 证书登录测试,身份证 110101199001011234
--
-- 内置应用:示例子系统(demo-subsystem)、示例子系统二(demo-subsystem-2)
--
-- 上线前删除测试账号(admin 保留):
-- DELETE FROM user_app_selection
-- WHERE user_id IN (SELECT id FROM sys_user
-- WHERE username IN ('demo','test','sso_test','cert_test'));
-- DELETE FROM sys_user
-- WHERE username IN ('demo','test','sso_test','cert_test');
-- 并立即修改 admin 密码。
--
-- 已包含 flyway_schema_history,门户启动时会跳过已执行的迁移。
-- 示例应用的 client_secret_cipher 为空,首次启动会用部署方的
-- ZHIHUI_CLIENT_SECRET_KEY 重新加密。
-- ============================================================
-- MySQL dump 10.13 Distrib 8.0.31, for Win64 (x86_64)
--
-- Host: 127.0.0.1 Database: zhihui_seed
-- ------------------------------------------------------
-- Server version 8.0.31
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `audit_log`
--
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `audit_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`event_type` varchar(48) COLLATE utf8mb4_unicode_ci NOT NULL,
`user_id` bigint DEFAULT NULL,
`username` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`client_ip` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`user_agent` varchar(300) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`target_type` varchar(48) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`target_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`detail` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`success` tinyint(1) NOT NULL DEFAULT '1',
`created_at` datetime(6) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_audit_log_created` (`created_at`),
KEY `idx_audit_log_event` (`event_type`,`created_at`),
KEY `idx_audit_log_username` (`username`,`created_at`),
KEY `idx_audit_log_target` (`target_type`,`target_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `audit_log`
--
--
-- Table structure for table `flyway_schema_history`
--
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flyway_schema_history` (
`installed_rank` int NOT NULL,
`version` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`description` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL,
`type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL,
`script` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL,
`checksum` int DEFAULT NULL,
`installed_by` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`installed_on` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`execution_time` int NOT NULL,
`success` tinyint(1) NOT NULL,
PRIMARY KEY (`installed_rank`),
KEY `flyway_schema_history_s_idx` (`success`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `flyway_schema_history`
--
INSERT INTO `flyway_schema_history` VALUES (1,'1','init','SQL','V1__init.sql',365313344,'zhihui','2026-11-02 10:57:02',98,1),(2,'2','user app order','SQL','V2__user_app_order.sql',-959481751,'zhihui','2026-11-02 10:57:02',17,1),(3,'3','personal launcher','SQL','V3__personal_launcher.sql',1538043215,'zhihui','2026-11-02 10:57:02',54,1),(4,'4','market only','SQL','V4__market_only.sql',1554031860,'zhihui','2026-11-02 10:57:02',10,1),(5,'5','user app selection','SQL','V5__user_app_selection.sql',-2099363095,'zhihui','2026-11-02 10:57:02',26,1),(6,'6','audit log','SQL','V6__audit_log.sql',-2083195916,'zhihui','2026-11-02 10:57:02',25,1),(7,'7','compact launcher positions','SQL','V7__compact_launcher_positions.sql',-31191275,'zhihui','2026-11-02 10:57:02',5,1),(8,'8','app client secret cipher','SQL','V8__app_client_secret_cipher.sql',-926358210,'zhihui','2026-11-02 10:57:02',9,1),(9,'9','normalize loopback audit ip','SQL','V9__normalize_loopback_audit_ip.sql',-641626764,'zhihui','2026-11-02 10:57:02',2,1),(10,'10','user police number','SQL','V10__user_police_number.sql',-935741005,'zhihui','2026-11-02 10:57:02',21,1),(11,'11','admin without police number','SQL','V11__admin_without_police_number.sql',-1730839204,'zhihui','2026-11-02 10:57:02',1,1),(12,'12','user login identifiers','SQL','V12__user_login_identifiers.sql',1855543215,'zhihui','2026-11-02 10:57:02',27,1),(13,'13','revoke builtin account subsystem authorizations','SQL','V13__revoke_builtin_account_subsystem_authorizations.sql',-1393725349,'zhihui','2026-11-02 10:57:02',4,1),(14,'14','zhijing app logo','SQL','V14__zhijing_app_logo.sql',1174232041,'zhihui','2026-11-02 10:57:02',2,1);
--
-- Table structure for table `oauth2_authorization`
--
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `oauth2_authorization` (
`id` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`registered_client_id` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`principal_name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL,
`authorization_grant_type` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`authorized_scopes` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`attributes` blob,
`state` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`authorization_code_value` blob,
`authorization_code_issued_at` timestamp NULL DEFAULT NULL,
`authorization_code_expires_at` timestamp NULL DEFAULT NULL,
`authorization_code_metadata` blob,
`access_token_value` blob,
`access_token_issued_at` timestamp NULL DEFAULT NULL,
`access_token_expires_at` timestamp NULL DEFAULT NULL,
`access_token_metadata` blob,
`access_token_type` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`access_token_scopes` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`oidc_id_token_value` blob,
`oidc_id_token_issued_at` timestamp NULL DEFAULT NULL,
`oidc_id_token_expires_at` timestamp NULL DEFAULT NULL,
`oidc_id_token_metadata` blob,
`refresh_token_value` blob,
`refresh_token_issued_at` timestamp NULL DEFAULT NULL,
`refresh_token_expires_at` timestamp NULL DEFAULT NULL,
`refresh_token_metadata` blob,
`user_code_value` blob,
`user_code_issued_at` timestamp NULL DEFAULT NULL,
`user_code_expires_at` timestamp NULL DEFAULT NULL,
`user_code_metadata` blob,
`device_code_value` blob,
`device_code_issued_at` timestamp NULL DEFAULT NULL,
`device_code_expires_at` timestamp NULL DEFAULT NULL,
`device_code_metadata` blob,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `oauth2_authorization`
--
--
-- Table structure for table `oauth2_authorization_consent`
--
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `oauth2_authorization_consent` (
`registered_client_id` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`principal_name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL,
`authorities` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`registered_client_id`,`principal_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `oauth2_authorization_consent`
--
--
-- Table structure for table `oauth2_registered_client`
--
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `oauth2_registered_client` (
`id` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`client_id` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`client_id_issued_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`client_secret` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`client_secret_expires_at` timestamp NULL DEFAULT NULL,
`client_name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL,
`client_authentication_methods` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL,
`authorization_grant_types` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL,
`redirect_uris` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`post_logout_redirect_uris` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`scopes` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL,
`client_settings` varchar(2000) COLLATE utf8mb4_unicode_ci NOT NULL,
`token_settings` varchar(2000) COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `oauth2_registered_client`
--
INSERT INTO `oauth2_registered_client` VALUES ('2436893b-c1db-47dd-9cc4-b28eea89bd4e','demo-subsystem-2','2026-11-02 10:57:08','{bcrypt}$2a$10$iogCPfuoTqq0NtvTbA3nEekK9A/raVmTYeaNyp1Wu70D0UZ2cOAtG',NULL,'示例子系统二','client_secret_post,client_secret_basic','refresh_token,authorization_code','http://localhost:8082/sso/callback','http://localhost:8082/','openid,profile,email','{\"@class\":\"java.util.Collections$UnmodifiableMap\",\"settings.client.require-proof-key\":false,\"settings.client.require-authorization-consent\":false}','{\"@class\":\"java.util.Collections$UnmodifiableMap\",\"settings.token.reuse-refresh-tokens\":false,\"settings.token.x509-certificate-bound-access-tokens\":false,\"settings.token.id-token-signature-algorithm\":[\"org.springframework.security.oauth2.jose.jws.SignatureAlgorithm\",\"RS256\"],\"settings.token.access-token-time-to-live\":[\"java.time.Duration\",1800.000000000],\"settings.token.access-token-format\":{\"@class\":\"org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat\",\"value\":\"self-contained\"},\"settings.token.refresh-token-time-to-live\":[\"java.time.Duration\",28800.000000000],\"settings.token.authorization-code-time-to-live\":[\"java.time.Duration\",300.000000000],\"settings.token.device-code-time-to-live\":[\"java.time.Duration\",300.000000000]}'),('7c3e9876-74e4-4cf0-8f09-3c32e7bd140c','demo-subsystem','2026-11-02 10:57:08','{bcrypt}$2a$10$M968t.xG75QnXTXKtMQZ9ufl7JpzqKMK.20s/CWXYxfne6oNSmRoC',NULL,'示例子系统','client_secret_post,client_secret_basic','refresh_token,authorization_code','http://localhost:8081/sso/callback','http://localhost:8081/','openid,profile,email','{\"@class\":\"java.util.Collections$UnmodifiableMap\",\"settings.client.require-proof-key\":false,\"settings.client.require-authorization-consent\":false}','{\"@class\":\"java.util.Collections$UnmodifiableMap\",\"settings.token.reuse-refresh-tokens\":false,\"settings.token.x509-certificate-bound-access-tokens\":false,\"settings.token.id-token-signature-algorithm\":[\"org.springframework.security.oauth2.jose.jws.SignatureAlgorithm\",\"RS256\"],\"settings.token.access-token-time-to-live\":[\"java.time.Duration\",1800.000000000],\"settings.token.access-token-format\":{\"@class\":\"org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat\",\"value\":\"self-contained\"},\"settings.token.refresh-token-time-to-live\":[\"java.time.Duration\",28800.000000000],\"settings.token.authorization-code-time-to-live\":[\"java.time.Duration\",300.000000000],\"settings.token.device-code-time-to-live\":[\"java.time.Duration\",300.000000000]}');
--
-- Table structure for table `portal_app`
--
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `portal_app` (
`id` bigint NOT NULL AUTO_INCREMENT,
`app_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`app_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`app_url` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL,
`icon_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`description` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`client_id` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`sort_order` int NOT NULL DEFAULT '0',
`status` tinyint NOT NULL DEFAULT '1',
`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
`client_secret_cipher` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_portal_app_code` (`app_code`),
UNIQUE KEY `uk_portal_app_client` (`client_id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `portal_app`
--
INSERT INTO `portal_app` VALUES (1,'demo','示例子系统','http://localhost:8081','/img/app-demo.svg','演示一次登录后直接进入的业务子系统','demo-subsystem',10,1,'2026-11-02 10:57:07.960066','2026-11-02 18:58:34.122867',NULL),(2,'demo2','示例子系统二','http://localhost:8082','/img/app-demo-2.svg','第二个演示子系统,用于测试单点登录和个人排序','demo-subsystem-2',20,1,'2026-11-02 10:57:08.050665','2026-11-02 18:58:34.122867',NULL);
--
-- Table structure for table `sso_jwk`
--
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `sso_jwk` (
`id` int NOT NULL,
`kid` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`public_key` text COLLATE utf8mb4_unicode_ci NOT NULL,
`private_key` text COLLATE utf8mb4_unicode_ci NOT NULL,
`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `sso_jwk`
--
INSERT INTO `sso_jwk` VALUES (1,'420b876a-f3df-41cb-abea-c7dde2bf0b01','MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwUg8/g3m+CIEeIMrKuoipS9X8ogwAokijvwdc87FJNtOeap3L7IPloThNZRERbRucJKMMln0GBRRICT51KR6dlV9fvVqZ5XDdc0Mx7Qa0rrTlayc9vDa7hani9SMKyComBQrzRLSDzG8jAMV07X+QQu2oR/8NfqvNEhf3JYjLguiRrIVr/uV5U0aVm+p4C8FeA4kwwMWdXCshRyqVjbm3yxTW6pDPXucjsfQo8+bADXRty7pBeuFQrGC40XR/sVaQFonNa8Ng9vjMWie96bTRv1vH6WwW/m4nGR2Wj1QwqUt1gRm5taMN8Un/DWLOW3iM6epqhWOraSHqhdIGK61vwIDAQAB','MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDBSDz+Deb4IgR4gysq6iKlL1fyiDACiSKO/B1zzsUk2055qncvsg+WhOE1lERFtG5wkowyWfQYFFEgJPnUpHp2VX1+9WpnlcN1zQzHtBrSutOVrJz28NruFqeL1IwrIKiYFCvNEtIPMbyMAxXTtf5BC7ahH/w1+q80SF/cliMuC6JGshWv+5XlTRpWb6ngLwV4DiTDAxZ1cKyFHKpWNubfLFNbqkM9e5yOx9Cjz5sANdG3LukF64VCsYLjRdH+xVpAWic1rw2D2+MxaJ73ptNG/W8fpbBb+bicZHZaPVDCpS3WBGbm1ow3xSf8NYs5beIzp6mqFY6tpIeqF0gYrrW/AgMBAAECggEAAczJXeKgT5gXZltGkOaxE+d3s60+DaLwdSJR0IceUPy1o6Rq906IJV4RG69CNggtzx1OQwjvoP7Ufl3TDIUp62xnjAv4Z2Mt3cEU/S0CGNWOQMRC9ijqJ8Luqz4PsPLqmEhjmHStd0V+q8yZB6Y5mWbyUAeAOZIl94vwyXCuLbdegeWtywvcRxIoy164Wgh3vwixfic45MExiEYL+q0QNudYTCADU3PVHFtGGtEShIuNHJbQHcQb+ItwtHOyQpApWH+KOw1SB4CqpmdcT6Z7yFZfU+OoE+p+YddrQ+nWMgZJSYrxeXw15AKecBLg3HIrkHUN8tFDU/IEGSWyPAwYwQKBgQDiIelq0nTuxT0IAx8Qb8VeZRprWgV5kEbU2m0pR3mRXJZOwWMuDLi2rbtt6iyt2ccXshO1/DOT5ZdJgzPhW3kvxETtv11K2J2gQisEAMLcOyKu+BrTk9DvgDlU8ndcANj5w0XAefjbU7U3ckttxnLu/x5goOXka25TtarYIT9+hwKBgQDaz5QUFlHdQ4LWhMsb0QeBInx3ZjfadFDMMI7QJEpiEDzPfOsIbwRckmH7UxrRAidi+re8Sbv7mQtk9JwPvs4DtNh5Gn4UAczx0Vgx2m59lwEgsir5v8BTnEB6WEyIjTrf/TCJMVjgCsqm6OjtmwlAV7ADl7Cp7aswxgQ69ThlCQKBgQCID9GLpdAX7vO3A3HIiQgfsQPBfiv3JhmIb1jpUolSLnx+LJxJk/v8ZvJ0w0dY38WMe2iiwnoiTi3GXLx1p312gUjcKJ2wsD+/srCBOxgEPUy0LLKvhaxcvzE/7Jhm8lebiKRKmNlk1qweDZZXGeYD6dtUVtucoguDMLb4TqEOtwKBgDrvdj8C1AH40SDem6ZeYtsRYtz2F00rzydwXMcLP02yee6ZAuU+obodY27CL5LkNZ+fUCXS5mMaEcubZv2Gsw6kgzn7PMI+yejILi0fXGtPZOpdkt26HB9pEqTha5vMDnOOSqhpNjsCLRtP2I1HGPLPq4UMMnVSoq18aF9I9WuhAoGBAMvsXNlaKGCD7mA6FqL/wkvj984KIBf1VALZ9xyKku4wdJBjbcr/5s6OfyU8zs39LEjPDLiMlC0tDuNqF/R4N0sRz10iSH4LDxTCeMziHKjEB8ub3hJEDrDKrOYXu69dqgGINh6/mvHgAAaeLJtlCikvRbzHTsZHbZx8CtPpehS0','2026-11-02 10:57:05.271717');
--
-- Table structure for table `sys_user`
--
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `sys_user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`password` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`display_name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`police_number` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`id_card_number` varchar(18) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`email` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`phone` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`role` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USER',
`status` tinyint NOT NULL DEFAULT '1',
`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_sys_user_username` (`username`),
UNIQUE KEY `uk_sys_user_police_number` (`police_number`),
UNIQUE KEY `uk_sys_user_id_card_number` (`id_card_number`),
UNIQUE KEY `uk_sys_user_phone` (`phone`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `sys_user`
--
INSERT INTO `sys_user` VALUES (1,'admin','{bcrypt}$2a$10$YOm0y//Vl4n.V.7xvpiwXOLvENxor9evHEwzLS/VTmxKvCLYCSHT.','系统管理员',NULL,NULL,NULL,NULL,'ADMIN',1,'2026-11-02 10:57:07.866647','2026-11-02 10:57:07.866647'),(2,'demo','{bcrypt}$2a$10$RShdXLqXZkT231VH9SsWZu3/O0ucUeAc1Q3y2HwgX3E.aZgiOh3z2','演示用户','000002',NULL,NULL,NULL,'USER',1,'2026-11-02 10:57:07.937906','2026-11-02 10:57:07.937906'),(3,'test','$2a$10$A6fJdgHvjIODjJwSS5j7yO/BdRZKtM6TxQEaJPGnqrdMPPYMGWAzK','秦玲','660909',NULL,NULL,NULL,'USER',1,'2026-11-02 18:58:34.116989','2026-11-02 18:58:34.116989'),(4,'sso_test','$2a$10$zz0k2OZuh04uXBADtHt.GOap1Yhob5y4ctJgt01Akp4KKVLpJ7J3O','SSO测试账号','661282',NULL,NULL,NULL,'USER',1,'2026-11-02 18:58:34.116989','2026-11-02 18:58:34.116989'),(5,'cert_test','$2a$10$lJ8IeIUvnus84KaXrKVWlOapDVFases5ph6JEK6LzXoeQYHi/F3Cm','证书测试','CERTTEST','110101199001011234',NULL,NULL,'USER',1,'2026-11-02 18:58:34.116989','2026-11-02 18:58:34.116989');
--
-- Table structure for table `user_app_selection`
--
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `user_app_selection` (
`user_id` bigint NOT NULL,
`app_id` bigint NOT NULL,
`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (`user_id`,`app_id`),
KEY `idx_user_app_selection_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `user_app_selection`
--
INSERT INTO `user_app_selection` VALUES (1,1,'2026-11-02 10:57:08.111493'),(1,2,'2026-11-02 10:57:08.112029'),(2,1,'2026-11-02 10:57:08.112505'),(2,2,'2026-11-02 10:57:08.112878'),(3,1,'2026-11-02 18:58:34.119929'),(3,2,'2026-11-02 18:58:34.119929'),(4,1,'2026-11-02 18:58:34.119929'),(4,2,'2026-11-02 18:58:34.119929'),(5,1,'2026-11-02 18:58:34.119929'),(5,2,'2026-11-02 18:58:34.119929');
--
-- Table structure for table `user_launcher_order`
--
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `user_launcher_order` (
`user_id` bigint NOT NULL,
`item_type` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL,
`item_id` bigint NOT NULL,
`sort_order` int NOT NULL,
PRIMARY KEY (`user_id`,`item_type`,`item_id`),
KEY `idx_user_launcher_order_user` (`user_id`,`sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `user_launcher_order`
--
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2026-11-02 18:58:41
......@@ -31,6 +31,7 @@ zhihui:
client-secret: ${ZHIHUI_DEMO_CLIENT_SECRET:demo-secret-2026}
redirect-uri: ${ZHIHUI_DEMO_REDIRECT_URI:http://localhost:8081/sso/callback}
post-logout-redirect-uri: ${ZHIHUI_DEMO_POST_LOGOUT_URI:http://localhost:8081/}
session-revalidate-interval: ${ZHIHUI_SSO_SESSION_REVALIDATE_INTERVAL:60s}
logging:
level:
......
package com.zhihui.portal;
import com.zhihui.portal.cert.CertificateAuthProperties;
import com.zhihui.portal.config.PortalSecurityProperties;
import com.zhihui.portal.config.ZhihuiProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableConfigurationProperties({ZhihuiProperties.class, PortalSecurityProperties.class})
@EnableScheduling
@EnableConfigurationProperties({ZhihuiProperties.class, PortalSecurityProperties.class,
CertificateAuthProperties.class})
public class ZhihuiApplication {
public static void main(String[] args) {
......
package com.zhihui.portal.app;
import com.zhihui.portal.auth.PortalAccounts;
import com.zhihui.portal.auth.SecurityUser;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 在 OAuth 授权码流程入口校验应用状态和个人应用关系,防止绕过门户应用目录直接进入未开通应用。
*/
public class AppAuthorizationFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(AppAuthorizationFilter.class);
private static final RequestMatcher AUTHORIZE_MATCHER =
PathPatternRequestMatcher.withDefaults().matcher("/oauth2/authorize");
private final PortalAppRepository appRepository;
private final JdbcTemplate jdbcTemplate;
public AppAuthorizationFilter(PortalAppRepository appRepository, JdbcTemplate jdbcTemplate) {
this.appRepository = appRepository;
this.jdbcTemplate = jdbcTemplate;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !AUTHORIZE_MATCHER.matches(request);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null
|| !(authentication.getPrincipal() instanceof SecurityUser user)) {
filterChain.doFilter(request, response);
return;
}
String[] clientIds = request.getParameterValues("client_id");
if (clientIds == null || clientIds.length == 0) {
filterChain.doFilter(request, response);
return;
}
if (clientIds.length != 1 || !StringUtils.hasText(clientIds[0])) {
deny(request, response, HttpServletResponse.SC_BAD_REQUEST, "授权请求参数不合法");
return;
}
String clientId = clientIds[0].trim();
PortalApp app = appRepository.findByClientId(clientId).orElse(null);
if (app == null) {
deny(request, response, HttpServletResponse.SC_FORBIDDEN, "该应用未在门户登记");
return;
}
boolean active = app.getStatus() != null && app.getStatus() == 1;
boolean selected = isSelected(user.getId(), app.getId());
boolean portalAccount = PortalAccounts.isUsernameAccount(user.getUsername());
if (!active || !selected || portalAccount) {
log.warn("OAuth 授权被拒绝: username={}, clientId={}, active={}, selected={}, portalAccount={}",
user.getUsername(), clientId, active, selected, portalAccount);
deny(request, response, HttpServletResponse.SC_FORBIDDEN, "当前账号未开通该应用,请从应用中心添加后再进入");
return;
}
filterChain.doFilter(request, response);
}
private boolean isSelected(Long userId, Long appId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM user_app_selection WHERE user_id = ? AND app_id = ?",
Integer.class, userId, appId);
return count != null && count > 0;
}
private static void deny(HttpServletRequest request, HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setCharacterEncoding("UTF-8");
if (acceptsJson(request)) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"access_denied\",\"message\":\"" + message + "\"}");
return;
}
response.setContentType("text/html;charset=UTF-8");
response.getWriter().write("""
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="data:,">
<title>无法访问应用</title>
<style>
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#eef3f9;
font-family:"Noto Sans SC","Microsoft YaHei",sans-serif;color:#16263a}
.box{max-width:480px;margin:24px;padding:36px 40px;background:#fff;
border-top:3px solid #b3262e;box-shadow:0 18px 50px rgba(13,31,51,.1)}
h1{margin:0 0 14px;font-size:21px;font-weight:600}
p{margin:0 0 22px;font-size:14px;line-height:1.9;color:#5f7288}
a{display:inline-block;padding:10px 20px;background:#1b4a78;color:#fff;
text-decoration:none;font-size:14px;border-radius:4px}
</style></head><body><div class="box"><h1>无法访问应用</h1><p>%s</p>
<a href="/spa/">返回应用中心</a></div></body></html>
""".formatted(message));
}
private static boolean acceptsJson(HttpServletRequest request) {
String accept = request.getHeader("Accept");
return accept != null && accept.contains("application/json");
}
}
......@@ -9,6 +9,7 @@ import com.zhihui.portal.app.dto.AppDtos.SecretResponse;
import com.zhihui.portal.app.dto.AppDtos.UpdateAppRequest;
import com.zhihui.portal.app.dto.AppDtos.UpdateIntegrationRequest;
import com.zhihui.portal.common.BizException;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
......@@ -49,17 +50,20 @@ public class AppService {
private final PasswordEncoder passwordEncoder;
private final JdbcTemplate jdbcTemplate;
private final ClientSecretCipher secretCipher;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public AppService(PortalAppRepository appRepository,
RegisteredClientRepository registeredClientRepository,
PasswordEncoder passwordEncoder,
JdbcTemplate jdbcTemplate,
ClientSecretCipher secretCipher) {
ClientSecretCipher secretCipher,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.appRepository = appRepository;
this.registeredClientRepository = registeredClientRepository;
this.passwordEncoder = passwordEncoder;
this.jdbcTemplate = jdbcTemplate;
this.secretCipher = secretCipher;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
}
public List<AppResponse> listAll() {
......@@ -69,7 +73,7 @@ public class AppService {
}
/**
* 当前用户的门户:用户从市场添加的应用,按个人格子位置排列。
* 当前用户的门户:用户从智能体广场添加的应用,按个人格子位置排列。
*/
public List<AppCard> listCardsForUser(Long userId) {
List<PortalApp> active = appRepository.findAllByStatusOrderBySortOrderAscIdAsc(1);
......@@ -122,7 +126,7 @@ public class AppService {
}
/**
* 应用市场:所有启用中的应用,以及当前用户是否已经添加。
* 智能体广场:所有启用中的应用,以及当前用户是否已经添加。
*/
public List<MarketApp> listMarket(Long userId) {
Set<Long> selected = new HashSet<>(jdbcTemplate.queryForList(
......@@ -175,14 +179,19 @@ public class AppService {
@Transactional
public void hideAppForUser(Long userId, Long appId) {
appRepository.findById(appId)
.filter(app -> app.getStatus() != null && app.getStatus() == 1)
PortalApp app = appRepository.findById(appId)
.filter(item -> item.getStatus() != null && item.getStatus() == 1)
.orElseThrow(() -> new BizException("应用不存在"));
jdbcTemplate.update("DELETE FROM user_app_selection WHERE user_id = ? AND app_id = ?",
userId, appId);
jdbcTemplate.update(
"DELETE FROM user_launcher_order WHERE user_id = ? AND item_type = 'APP' AND item_id = ?",
userId, appId);
String username = jdbcTemplate.query(
"SELECT username FROM sys_user WHERE id = ?",
(rs, rowNum) -> rs.getString(1), userId)
.stream().findFirst().orElse(null);
oauthAuthorizationRevocationService.revokeByPrincipalAndClientId(username, app.getClientId());
}
@Transactional
......@@ -241,6 +250,7 @@ public class AppService {
@Transactional
public AppResponse update(Long id, UpdateAppRequest request) {
PortalApp app = find(id);
Integer oldStatus = app.getStatus();
String appUrl = validateUri(request.appUrl(), "访问地址");
List<String> redirectUris = normalizeRedirectUris(request.redirectUris(), appUrl);
List<String> postLogoutUris = normalizePostLogoutUris(request.postLogoutRedirectUris(), appUrl);
......@@ -275,7 +285,11 @@ public class AppService {
}
app.setStatus(request.status());
}
return AppResponse.from(appRepository.save(app), updated);
AppResponse response = AppResponse.from(appRepository.save(app), updated);
if (oldStatus != null && oldStatus != 0 && response.status() != null && response.status() == 0) {
oauthAuthorizationRevocationService.revokeByRegisteredClientId(existing.getId());
}
return response;
}
@Transactional
......
......@@ -9,6 +9,8 @@ public interface PortalAppRepository extends JpaRepository<PortalApp, Long> {
Optional<PortalApp> findByAppCode(String appCode);
Optional<PortalApp> findByClientId(String clientId);
boolean existsByAppCode(String appCode);
List<PortalApp> findAllByStatusOrderBySortOrderAscIdAsc(Integer status);
......
......@@ -5,11 +5,13 @@ public final class AuditDtos {
private AuditDtos() {
}
public record AuditLogEntry(Long id, String eventType, Long userId, String username,
String clientIp, String userAgent, String targetType,
String targetId, String detail, boolean success, String createdAt) {
public record AuditLogEntry(Long id, String eventType, String loginType, Long userId,
String username, String clientIp, String userAgent,
String targetType, String targetId, String targetName,
String detail, boolean success, String createdAt) {
}
public record LoginRecord(Long id, String clientIp, String userAgent, String createdAt) {
public record LoginRecord(Long id, String clientIp, String userAgent, String loginType,
String createdAt) {
}
}
......@@ -5,10 +5,13 @@ public final class AuditEvents {
public static final String LOGIN_SUCCESS = "LOGIN_SUCCESS";
public static final String LOGIN_FAILURE = "LOGIN_FAILURE";
public static final String LOGIN_BLOCKED = "LOGIN_BLOCKED";
public static final String LOGIN_IP_CHANGED = "LOGIN_IP_CHANGED";
public static final String LOGOUT = "LOGOUT";
public static final String USER_CREATE = "USER_CREATE";
public static final String USER_UPDATE = "USER_UPDATE";
public static final String USER_PASSWORD_CHANGE = "USER_PASSWORD_CHANGE";
public static final String USER_AUTO_DISABLE = "USER_AUTO_DISABLE";
public static final String USER_PASSWORD_RESET = "USER_PASSWORD_RESET";
public static final String USER_DELETE = "USER_DELETE";
public static final String USER_IMPORT = "USER_IMPORT";
......
......@@ -7,10 +7,12 @@ import com.zhihui.portal.common.BizException;
import com.zhihui.portal.common.PageResponse;
import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.LoginIdentifierResolver;
import com.zhihui.portal.security.LoginType;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowCallbackHandler;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
......@@ -25,7 +27,12 @@ import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class AuditLogService {
......@@ -37,12 +44,12 @@ public class AuditLogService {
private static final String INSERT_SQL = """
INSERT INTO audit_log
(event_type, user_id, username, client_ip, user_agent, target_type, target_id, detail, success, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(event_type, login_type, user_id, username, client_ip, user_agent, target_type, target_id, detail, success, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
private static final String SELECT_SQL = """
SELECT id, event_type, user_id, username, client_ip, user_agent,
SELECT id, event_type, login_type, user_id, username, client_ip, user_agent,
target_type, target_id, detail, success, created_at
FROM audit_log
""";
......@@ -54,12 +61,14 @@ public class AuditLogService {
return new AuditLogEntry(
rs.getLong("id"),
rs.getString("event_type"),
rs.getString("login_type"),
rs.getObject("user_id", Long.class),
rs.getString("username"),
rs.getString("client_ip"),
rs.getString("user_agent"),
rs.getString("target_type"),
rs.getString("target_id"),
null,
rs.getString("detail"),
rs.getBoolean("success"),
createdAtText);
......@@ -73,32 +82,45 @@ public class AuditLogService {
this.clientInfoResolver = clientInfoResolver;
}
public void recordLoginSuccess(SecurityUser user, String clientIp, String userAgent) {
record(AuditEvents.LOGIN_SUCCESS,
public void recordLoginSuccess(SecurityUser user, String clientIp, String userAgent,
String detail, LoginType loginType) {
record(AuditEvents.LOGIN_SUCCESS, loginType,
user == null ? null : user.getId(),
user == null ? null : user.getUsername(),
clientIp, userAgent, "AUTH", null, "登录成功", true);
clientIp, userAgent, "AUTH", null, detail, true);
}
public void recordLoginFailure(String username, String clientIp, String userAgent,
String reason, boolean blocked) {
String reason, boolean blocked, LoginType loginType) {
String detail = "原因:" + reason + (blocked ? ";已触发登录限流" : "");
record(AuditEvents.LOGIN_FAILURE, null,
record(AuditEvents.LOGIN_FAILURE, loginType, null,
LoginIdentifierResolver.maskForAudit(username),
clientIp, userAgent, "AUTH", null, detail, false);
}
public void recordLoginBlocked(String username, String clientIp, String userAgent,
String scope, long retryAfterSeconds) {
String scope, long retryAfterSeconds, LoginType loginType) {
String scopeLabel = "USER_IP".equals(scope) ? "账号+IP" : "IP";
String detail = "限流维度:" + scopeLabel + ";剩余 " + retryAfterSeconds + " 秒";
record(AuditEvents.LOGIN_BLOCKED, null,
record(AuditEvents.LOGIN_BLOCKED, loginType, null,
LoginIdentifierResolver.maskForAudit(username),
clientIp, userAgent, "AUTH", null, detail, false);
}
public void recordLoginIpChanged(SecurityUser user, String boundIp, String clientIp,
String userAgent, LoginType loginType) {
String detail = StringUtils.hasText(boundIp)
? "登录IP由 " + boundIp + " 变更为 " + clientIp
+ ",该用户全部门户会话及全部子系统授权已撤销"
: "登录会话缺少 IP 绑定,已强制重新登录";
record(AuditEvents.LOGIN_IP_CHANGED, loginType,
user == null ? null : user.getId(),
user == null ? null : user.getUsername(),
clientIp, userAgent, "AUTH", null, detail, false);
}
public void recordLogout(SecurityUser user, String clientIp, String userAgent) {
record(AuditEvents.LOGOUT,
record(AuditEvents.LOGOUT, null,
user == null ? null : user.getId(),
user == null ? null : user.getUsername(),
clientIp, userAgent, "AUTH", null, "退出登录", true);
......@@ -119,10 +141,14 @@ public class AuditLogService {
HttpServletRequest request = currentRequest();
String clientIp = clientInfoResolver.resolveIp(request);
String userAgent = clientInfoResolver.resolveUserAgent(request);
record(eventType, user == null ? null : user.getId(), username, clientIp, userAgent,
record(eventType, null, user == null ? null : user.getId(), username, clientIp, userAgent,
targetType, targetId, detail, success);
}
public void recordSystem(String eventType, String targetType, String targetId, String detail) {
record(eventType, null, null, "system", null, null, targetType, targetId, detail, true);
}
public List<LoginRecord> recentLoginRecords(Long userId, String username, int limit) {
int maxRows = Math.min(Math.max(limit, 1), 20);
StringBuilder where = new StringBuilder(
......@@ -137,7 +163,8 @@ public class AuditLogService {
params.add(maxRows);
return jdbcTemplate.query(SELECT_SQL + where, ROW_MAPPER, params.toArray()).stream()
.map(entry -> new LoginRecord(entry.id(), entry.clientIp(), entry.userAgent(), entry.createdAt()))
.map(entry -> new LoginRecord(entry.id(), entry.clientIp(), entry.userAgent(),
entry.loginType(), entry.createdAt()))
.toList();
}
......@@ -162,7 +189,10 @@ public class AuditLogService {
term = term.substring(0, 100);
}
String pattern = "%" + term + "%";
where.append(" AND (username LIKE ? OR client_ip LIKE ? OR target_id LIKE ? OR detail LIKE ?)");
where.append(" AND (username LIKE ? OR client_ip LIKE ? OR target_id LIKE ? OR detail LIKE ?"
+ " OR (target_type = 'APP' AND CAST(target_id AS UNSIGNED) IN"
+ " (SELECT id FROM portal_app WHERE app_name LIKE ?)))");
params.add(pattern);
params.add(pattern);
params.add(pattern);
params.add(pattern);
......@@ -185,16 +215,59 @@ public class AuditLogService {
List<AuditLogEntry> content = jdbcTemplate.query(
SELECT_SQL + where + " ORDER BY id DESC LIMIT ? OFFSET ?",
ROW_MAPPER, queryParams.toArray());
content = resolveAppNames(content);
return new PageResponse<>(content, total == null ? 0 : total, pageNumber, pageSize);
}
private void record(String eventType, Long userId, String username, String clientIp,
String userAgent, String targetType, String targetId,
private List<AuditLogEntry> resolveAppNames(List<AuditLogEntry> entries) {
Set<Long> appIds = new HashSet<>();
for (AuditLogEntry entry : entries) {
if (!"APP".equals(entry.targetType())) {
continue;
}
try {
appIds.add(Long.parseLong(entry.targetId()));
} catch (NumberFormatException | NullPointerException ignored) {
// 非数字对象 ID 不参与应用名称解析
}
}
if (appIds.isEmpty()) {
return entries;
}
String placeholders = String.join(",", Collections.nCopies(appIds.size(), "?"));
Map<Long, String> names = new HashMap<>();
jdbcTemplate.query("SELECT id, app_name FROM portal_app WHERE id IN (" + placeholders + ")",
(RowCallbackHandler) rs -> names.put(rs.getLong("id"), rs.getString("app_name")),
appIds.toArray());
return entries.stream()
.map(entry -> {
if (!"APP".equals(entry.targetType())) {
return entry;
}
try {
String name = names.get(Long.parseLong(entry.targetId()));
if (name == null) {
return entry;
}
return new AuditLogEntry(entry.id(), entry.eventType(), entry.loginType(),
entry.userId(), entry.username(), entry.clientIp(), entry.userAgent(),
entry.targetType(), entry.targetId(), name, entry.detail(),
entry.success(), entry.createdAt());
} catch (NumberFormatException | NullPointerException ignored) {
return entry;
}
})
.toList();
}
private void record(String eventType, LoginType loginType, Long userId, String username,
String clientIp, String userAgent, String targetType, String targetId,
String detail, boolean success) {
try {
jdbcTemplate.update(INSERT_SQL,
truncate(eventType, 48),
loginType == null ? null : loginType.name(),
userId,
truncate(username, 64),
truncate(clientIp, 64),
......
......@@ -52,7 +52,8 @@ public class DbUserDetailsService implements UserDetailsService {
user.getPhone(),
user.getEmail(),
user.getRole(),
user.getStatus() != null && user.getStatus() == 1
user.getStatus() != null && user.getStatus() == 1,
user.isMustChangePassword()
);
}
}
......@@ -32,10 +32,18 @@ public class SecurityUser implements UserDetails {
private final String email;
private final String role;
private final boolean enabled;
private final boolean mustChangePassword;
public SecurityUser(Long id, String username, String password, String displayName,
String policeNumber, String idCardNumber, String phoneNumber,
String email, String role, boolean enabled) {
this(id, username, password, displayName, policeNumber, idCardNumber, phoneNumber,
email, role, enabled, false);
}
public SecurityUser(Long id, String username, String password, String displayName,
String policeNumber, String idCardNumber, String phoneNumber,
String email, String role, boolean enabled, boolean mustChangePassword) {
this.id = id;
this.username = username;
this.password = password;
......@@ -46,6 +54,7 @@ public class SecurityUser implements UserDetails {
this.email = email;
this.role = role;
this.enabled = enabled;
this.mustChangePassword = mustChangePassword;
}
@JsonCreator
......@@ -57,9 +66,10 @@ public class SecurityUser implements UserDetails {
@JsonProperty("phoneNumber") String phoneNumber,
@JsonProperty("email") String email,
@JsonProperty("role") String role,
@JsonProperty("enabled") boolean enabled) {
@JsonProperty("enabled") boolean enabled,
@JsonProperty("mustChangePassword") Boolean mustChangePassword) {
this(id, username, null, displayName, policeNumber, idCardNumber, phoneNumber,
email, role, enabled);
email, role, enabled, Boolean.TRUE.equals(mustChangePassword));
}
public Long getId() {
......@@ -90,6 +100,11 @@ public class SecurityUser implements UserDetails {
return role;
}
@JsonProperty("mustChangePassword")
public boolean isMustChangePassword() {
return mustChangePassword;
}
@JsonIgnore
public List<String> getRoleList() {
return List.of(role);
......
......@@ -74,10 +74,12 @@ public class DataInitializer implements ApplicationRunner {
if (createdAdmin) {
log.warn("================================================================");
log.warn("已创建默认管理员:admin / {},请登录后立即修改密码!", properties.getAdminPassword());
log.warn("演示用户:demo / Demo@123");
log.warn("已创建默认管理员账号 admin,请使用配置的初始密码登录并立即修改密码!");
log.warn("================================================================");
}
if (createdDemo) {
log.warn("已创建演示用户账号 demo。");
}
log.info("统一认证发行方:{}", properties.getIssuer());
}
......
package com.zhihui.portal.cert;
import org.springframework.security.core.AuthenticationException;
import java.io.Serial;
public class CertificateAuthException extends AuthenticationException {
@Serial
private static final long serialVersionUID = 1L;
public enum Reason {
NOT_CONFIGURED,
CHALLENGE_INVALID,
GATEWAY_UNAVAILABLE,
GATEWAY_REJECTED,
CERT_INVALID,
NO_USER,
USER_DISABLED,
ACCOUNT_DENIED,
RATE_LIMITED
}
private final Reason reason;
private final String auditReason;
private final String auditIdentity;
private final boolean countFailure;
private final String errorCode;
private final long retryAfterSeconds;
public CertificateAuthException(Reason reason, String message, String auditReason,
String auditIdentity, boolean countFailure, String errorCode) {
this(reason, message, auditReason, auditIdentity, countFailure, errorCode, 0);
}
public CertificateAuthException(Reason reason, String message, String auditReason,
String auditIdentity, boolean countFailure, String errorCode,
long retryAfterSeconds) {
super(message);
this.reason = reason;
this.auditReason = auditReason;
this.auditIdentity = auditIdentity;
this.countFailure = countFailure;
this.errorCode = errorCode;
this.retryAfterSeconds = retryAfterSeconds;
}
public static CertificateAuthException rateLimited(long retryAfterSeconds) {
return new CertificateAuthException(Reason.RATE_LIMITED, "登录尝试过于频繁",
"证书登录触发限流", "证书用户", false, "locked", retryAfterSeconds);
}
public Reason getReason() {
return reason;
}
public String getAuditReason() {
return auditReason;
}
public String getAuditIdentity() {
return auditIdentity;
}
public boolean isCountFailure() {
return countFailure;
}
public String getErrorCode() {
return errorCode;
}
public long getRetryAfterSeconds() {
return retryAfterSeconds;
}
}
package com.zhihui.portal.cert;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
import java.time.Duration;
@ConfigurationProperties(prefix = "zhihui.cert-auth")
public class CertificateAuthProperties {
private boolean enabled = false;
private String gatewayUrl = "";
private String appId = "";
private String initParam = "";
private String initParamFile = "";
private boolean pinCode = false;
private boolean accessControl = false;
private boolean allowInsecureHttp = false;
private String attributesMode = "portion";
private String subjectDnIdAttribute = "CN";
private Duration connectTimeout = Duration.ofSeconds(5);
private Duration requestTimeout = Duration.ofSeconds(15);
private Duration challengeTtl = Duration.ofMinutes(2);
private int maxResponseBytes = 256 * 1024;
private int maxSignedDataBytes = 512 * 1024;
private String trustStore = "";
private String trustStorePassword = "";
private String trustStoreType = "PKCS12";
private String clientDownloadUrl = "";
public boolean isConfigured() {
return enabled && StringUtils.hasText(gatewayUrl) && StringUtils.hasText(appId);
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getGatewayUrl() {
return gatewayUrl;
}
public void setGatewayUrl(String gatewayUrl) {
this.gatewayUrl = gatewayUrl;
}
public String getAppId() {
return appId;
}
public void setAppId(String appId) {
this.appId = appId;
}
public String getInitParam() {
return initParam;
}
public void setInitParam(String initParam) {
this.initParam = initParam;
}
public String getInitParamFile() {
return initParamFile;
}
public void setInitParamFile(String initParamFile) {
this.initParamFile = initParamFile;
}
public boolean isPinCode() {
return pinCode;
}
public void setPinCode(boolean pinCode) {
this.pinCode = pinCode;
}
public boolean isAccessControl() {
return accessControl;
}
public void setAccessControl(boolean accessControl) {
this.accessControl = accessControl;
}
public boolean isAllowInsecureHttp() {
return allowInsecureHttp;
}
public void setAllowInsecureHttp(boolean allowInsecureHttp) {
this.allowInsecureHttp = allowInsecureHttp;
}
public String getAttributesMode() {
return attributesMode;
}
public void setAttributesMode(String attributesMode) {
this.attributesMode = attributesMode;
}
public String getSubjectDnIdAttribute() {
return subjectDnIdAttribute;
}
public void setSubjectDnIdAttribute(String subjectDnIdAttribute) {
this.subjectDnIdAttribute = subjectDnIdAttribute;
}
public Duration getConnectTimeout() {
return connectTimeout;
}
public void setConnectTimeout(Duration connectTimeout) {
this.connectTimeout = connectTimeout;
}
public Duration getRequestTimeout() {
return requestTimeout;
}
public void setRequestTimeout(Duration requestTimeout) {
this.requestTimeout = requestTimeout;
}
public Duration getChallengeTtl() {
return challengeTtl;
}
public void setChallengeTtl(Duration challengeTtl) {
this.challengeTtl = challengeTtl;
}
public int getMaxResponseBytes() {
return maxResponseBytes;
}
public void setMaxResponseBytes(int maxResponseBytes) {
this.maxResponseBytes = maxResponseBytes;
}
public int getMaxSignedDataBytes() {
return maxSignedDataBytes;
}
public void setMaxSignedDataBytes(int maxSignedDataBytes) {
this.maxSignedDataBytes = maxSignedDataBytes;
}
public String getTrustStore() {
return trustStore;
}
public void setTrustStore(String trustStore) {
this.trustStore = trustStore;
}
public String getTrustStorePassword() {
return trustStorePassword;
}
public void setTrustStorePassword(String trustStorePassword) {
this.trustStorePassword = trustStorePassword;
}
public String getTrustStoreType() {
return trustStoreType;
}
public void setTrustStoreType(String trustStoreType) {
this.trustStoreType = trustStoreType;
}
public String getClientDownloadUrl() {
return clientDownloadUrl;
}
public void setClientDownloadUrl(String clientDownloadUrl) {
this.clientDownloadUrl = clientDownloadUrl;
}
}
package com.zhihui.portal.cert;
import com.zhihui.portal.auth.PortalAccounts;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.security.LoginIdentifierResolver;
import com.zhihui.portal.user.SysUser;
import com.zhihui.portal.user.SysUserRepository;
import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;
@Service
public class CertificateAuthService {
static final String SESSION_CHALLENGE = CertificateAuthService.class.getName() + ".challenge";
static final String SESSION_EXPIRES_AT = CertificateAuthService.class.getName() + ".expiresAt";
private static final int CHALLENGE_BYTES = 32;
private final CertificateAuthProperties properties;
private final GatewayMessageClient gatewayClient;
private final CertificateSubjectParser subjectParser;
private final SysUserRepository userRepository;
private final SecureRandom secureRandom = new SecureRandom();
public CertificateAuthService(CertificateAuthProperties properties,
GatewayMessageClient gatewayClient,
CertificateSubjectParser subjectParser,
SysUserRepository userRepository) {
this.properties = properties;
this.gatewayClient = gatewayClient;
this.subjectParser = subjectParser;
this.userRepository = userRepository;
}
public boolean isEnabled() {
return properties.isConfigured() && gatewayClient.isAvailable();
}
public ChallengeResponse createChallenge(HttpSession session) {
if (!isEnabled()) {
throw notConfigured();
}
byte[] random = new byte[CHALLENGE_BYTES];
secureRandom.nextBytes(random);
String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
Duration ttl = properties.getChallengeTtl() == null
? Duration.ofMinutes(2) : properties.getChallengeTtl();
session.setAttribute(SESSION_CHALLENGE, challenge);
session.setAttribute(SESSION_EXPIRES_AT,
Instant.now().plus(ttl).toEpochMilli());
return new ChallengeResponse(challenge, Math.max(1, ttl.toSeconds()));
}
public SecurityUser authenticate(HttpSession session, String submittedOriginal,
String signedData, String clientIp) {
if (!isEnabled()) {
throw notConfigured();
}
String challenge = consumeChallenge(session, submittedOriginal);
GatewayMessageClient.GatewayAuthResult result =
gatewayClient.authenticate(clientIp, challenge, signedData);
if (!result.success()) {
String code = StringUtils.hasText(result.errorCode())
? result.errorCode().trim() : "UNKNOWN";
throw new CertificateAuthException(
CertificateAuthException.Reason.GATEWAY_REJECTED,
"证书认证失败",
"证书认证失败(网关错误码: " + code + ")",
"证书用户",
true,
"cert_failed");
}
String idCardNumber = subjectParser.extractIdCardNumber(result.subjectDn())
.orElseThrow(() -> new CertificateAuthException(
CertificateAuthException.Reason.CERT_INVALID,
"证书主题中未找到身份证号",
"证书认证失败(证书主题缺少身份证号)",
"证书用户",
true,
"cert_failed"));
String maskedIdentity = LoginIdentifierResolver.maskForAudit(idCardNumber);
Optional<SysUser> found = userRepository.findByIdCardNumber(idCardNumber);
if (found.isEmpty()) {
throw new CertificateAuthException(
CertificateAuthException.Reason.NO_USER,
"该证书未关联门户账号",
"证书未关联门户账号",
maskedIdentity,
false,
"cert_no_user");
}
SysUser user = found.get();
if (user.getStatus() == null || user.getStatus() != 1) {
throw new CertificateAuthException(
CertificateAuthException.Reason.USER_DISABLED,
"账号已停用",
"账号已停用",
maskedIdentity,
false,
"cert_disabled");
}
if (PortalAccounts.isUsernameAccount(user.getUsername())) {
throw new CertificateAuthException(
CertificateAuthException.Reason.ACCOUNT_DENIED,
"该账号不支持数字证书登录",
"内置账号不支持数字证书登录",
maskedIdentity,
false,
"cert_failed");
}
return new SecurityUser(
user.getId(),
user.getUsername(),
null,
user.getDisplayName(),
user.getPoliceNumber(),
user.getIdCardNumber(),
user.getPhone(),
user.getEmail(),
user.getRole(),
true,
user.isMustChangePassword());
}
private String consumeChallenge(HttpSession session, String submittedOriginal) {
String expected = (String) session.getAttribute(SESSION_CHALLENGE);
Object expiresValue = session.getAttribute(SESSION_EXPIRES_AT);
session.removeAttribute(SESSION_CHALLENGE);
session.removeAttribute(SESSION_EXPIRES_AT);
if (!StringUtils.hasText(expected) || !(expiresValue instanceof Long expiresAt)) {
throw challengeInvalid();
}
if (Instant.now().toEpochMilli() > expiresAt) {
throw challengeInvalid();
}
String submitted = submittedOriginal == null ? "" : submittedOriginal.trim();
if (submitted.length() > 128 || !MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
submitted.getBytes(StandardCharsets.UTF_8))) {
throw challengeInvalid();
}
return expected;
}
private static CertificateAuthException notConfigured() {
return new CertificateAuthException(
CertificateAuthException.Reason.NOT_CONFIGURED,
"数字证书登录暂不可用",
"数字证书登录未配置",
"证书用户",
false,
"cert_unavailable");
}
private static CertificateAuthException challengeInvalid() {
return new CertificateAuthException(
CertificateAuthException.Reason.CHALLENGE_INVALID,
"认证原文无效或已过期",
"证书认证失败(认证原文无效或已过期)",
"证书用户",
true,
"cert_failed");
}
public record ChallengeResponse(String original, long expiresInSeconds) {
}
}
package com.zhihui.portal.cert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@Component
public class CertificateAuthSettings {
private static final Logger log = LoggerFactory.getLogger(CertificateAuthSettings.class);
private final CertificateAuthService authService;
private final CertificateAuthProperties properties;
private final String initParam;
public CertificateAuthSettings(CertificateAuthService authService,
CertificateAuthProperties properties) {
this.authService = authService;
this.properties = properties;
this.initParam = resolveInitParam(properties);
if (properties.isEnabled() && !StringUtils.hasText(initParam)) {
log.warn("数字证书登录缺少 PNXClient 初始化参数,登录页入口不会显示");
}
}
public boolean isClientReady() {
return authService.isEnabled() && StringUtils.hasText(initParam);
}
public boolean isPinCode() {
return properties.isPinCode();
}
public String getInitParam() {
return initParam;
}
public String getClientDownloadUrl() {
return properties.getClientDownloadUrl();
}
private static String resolveInitParam(CertificateAuthProperties properties) {
if (StringUtils.hasText(properties.getInitParam())) {
return properties.getInitParam().trim();
}
if (!StringUtils.hasText(properties.getInitParamFile())) {
return "";
}
try {
String file = properties.getInitParamFile().trim();
Path path = file.startsWith("file:") ? Path.of(URI.create(file)) : Path.of(file);
return Files.readString(path, StandardCharsets.UTF_8).trim();
} catch (IOException | IllegalArgumentException ex) {
log.warn("读取 PNXClient 初始化参数失败: {}", ex.getMessage());
return "";
}
}
}
package com.zhihui.portal.cert;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.LoginAttemptService;
import com.zhihui.portal.security.LoginSuccessHandler;
import com.zhihui.portal.security.LoginType;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import java.io.IOException;
public class CertificateAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private final CertificateAuthService authService;
private final ClientInfoResolver clientInfoResolver;
private final LoginAttemptService loginAttemptService;
private final AuditLogService auditLogService;
public CertificateAuthenticationFilter(CertificateAuthService authService,
LoginSuccessHandler successHandler,
CertificateLoginFailureHandler failureHandler,
ClientInfoResolver clientInfoResolver,
LoginAttemptService loginAttemptService,
AuditLogService auditLogService) {
super(PathPatternRequestMatcher.withDefaults().matcher("/login/cert/authenticate"));
this.authService = authService;
this.clientInfoResolver = clientInfoResolver;
this.loginAttemptService = loginAttemptService;
this.auditLogService = auditLogService;
setAuthenticationSuccessHandler(successHandler);
setAuthenticationFailureHandler(failureHandler);
setSessionAuthenticationStrategy(new ChangeSessionIdAuthenticationStrategy());
setSecurityContextRepository(new HttpSessionSecurityContextRepository());
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
String clientIp = clientInfoResolver.resolveIp(request);
LoginAttemptService.Decision decision = loginAttemptService.check(null, clientIp);
if (decision.blocked()) {
auditLogService.recordLoginBlocked(null, clientIp,
clientInfoResolver.resolveUserAgent(request),
decision.scope(), decision.retryAfterSeconds(), LoginType.CERTIFICATE);
throw CertificateAuthException.rateLimited(decision.retryAfterSeconds());
}
HttpSession session = request.getSession(false);
if (session == null) {
throw new CertificateAuthException(
CertificateAuthException.Reason.CHALLENGE_INVALID,
"认证原文无效或已过期",
"证书认证失败(认证原文无效或已过期)",
"证书用户",
true,
"cert_failed");
}
SecurityUser user = authService.authenticate(
session,
request.getParameter("original"),
request.getParameter("signed_data"),
clientIp);
request.setAttribute(LoginSuccessHandler.LOGIN_DETAIL_ATTRIBUTE,
"登录成功(数字证书)");
request.setAttribute(LoginSuccessHandler.LOGIN_TYPE_ATTRIBUTE,
LoginType.CERTIFICATE.name());
return UsernamePasswordAuthenticationToken.authenticated(
user, null, user.getAuthorities());
}
@Override
protected boolean requiresAuthentication(HttpServletRequest request,
HttpServletResponse response) {
return "POST".equalsIgnoreCase(request.getMethod())
&& super.requiresAuthentication(request, response);
}
}
package com.zhihui.portal.cert;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/login/cert")
public class CertificateLoginController {
private final CertificateAuthService authService;
private final CertificateAuthSettings settings;
public CertificateLoginController(CertificateAuthService authService,
CertificateAuthSettings settings) {
this.authService = authService;
this.settings = settings;
}
@GetMapping("/config")
public ResponseEntity<CertificateLoginConfig> config() {
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.body(new CertificateLoginConfig(settings.isClientReady(), settings.isPinCode(),
settings.getInitParam(), settings.getClientDownloadUrl()));
}
@PostMapping("/challenge")
public ResponseEntity<?> challenge(HttpSession session) {
if (!authService.isEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.cacheControl(CacheControl.noStore())
.body(Map.of("message", "数字证书登录暂不可用"));
}
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.body(authService.createChallenge(session));
}
public record CertificateLoginConfig(boolean enabled, boolean pinCode, String initParam,
String clientDownloadUrl) {
}
}
package com.zhihui.portal.cert;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.LoginAttemptService;
import com.zhihui.portal.security.LoginType;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import java.io.IOException;
public class CertificateLoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private static final String DEFAULT_IDENTITY = "证书用户";
private final LoginAttemptService loginAttemptService;
private final ClientInfoResolver clientInfoResolver;
private final AuditLogService auditLogService;
public CertificateLoginFailureHandler(LoginAttemptService loginAttemptService,
ClientInfoResolver clientInfoResolver,
AuditLogService auditLogService) {
super("/login?error=cert_failed");
this.loginAttemptService = loginAttemptService;
this.clientInfoResolver = clientInfoResolver;
this.auditLogService = auditLogService;
}
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String clientIp = clientInfoResolver.resolveIp(request);
String userAgent = clientInfoResolver.resolveUserAgent(request);
if (!(exception instanceof CertificateAuthException certException)) {
auditLogService.recordLoginFailure(DEFAULT_IDENTITY, clientIp, userAgent,
"证书登录失败", false, LoginType.CERTIFICATE);
super.onAuthenticationFailure(request, response, exception);
return;
}
if (certException.getReason() == CertificateAuthException.Reason.RATE_LIMITED) {
response.setHeader("Retry-After", String.valueOf(certException.getRetryAfterSeconds()));
getRedirectStrategy().sendRedirect(request, response,
request.getContextPath() + "/login?error=locked");
return;
}
LoginAttemptService.Decision decision = null;
if (certException.isCountFailure()) {
decision = loginAttemptService.recordFailure(null, clientIp);
}
boolean blocked = decision != null && decision.blocked();
String identity = certException.getAuditIdentity() == null
? DEFAULT_IDENTITY : certException.getAuditIdentity();
auditLogService.recordLoginFailure(identity, clientIp, userAgent,
certException.getAuditReason(), blocked, LoginType.CERTIFICATE);
if (blocked) {
response.setHeader("Retry-After", String.valueOf(decision.retryAfterSeconds()));
getRedirectStrategy().sendRedirect(request, response,
request.getContextPath() + "/login?error=locked");
return;
}
getRedirectStrategy().sendRedirect(request, response,
request.getContextPath() + "/login?error=" + safeErrorCode(certException.getErrorCode()));
}
private static String safeErrorCode(String code) {
if (code == null || !code.matches("[a-z_]{1,32}")) {
return "cert_failed";
}
return code;
}
}
package com.zhihui.portal.cert;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.naming.InvalidNameException;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
public class CertificateSubjectParser {
private static final Pattern ID_CARD = Pattern.compile("(?<!\\d)(\\d{17}[0-9Xx])(?!\\d)");
private final String idAttribute;
@Autowired
public CertificateSubjectParser(CertificateAuthProperties properties) {
this(properties.getSubjectDnIdAttribute());
}
CertificateSubjectParser(String idAttribute) {
this.idAttribute = StringUtils.hasText(idAttribute) ? idAttribute.trim() : "CN";
}
public Optional<String> extractIdCardNumber(String subjectDn) {
if (!StringUtils.hasText(subjectDn)) {
return Optional.empty();
}
final LdapName name;
try {
name = new LdapName(subjectDn.trim());
} catch (InvalidNameException ex) {
return Optional.empty();
}
Set<String> matches = new LinkedHashSet<>();
for (Rdn rdn : name.getRdns()) {
if (!idAttribute.equalsIgnoreCase(rdn.getType())) {
continue;
}
Object value = rdn.getValue();
if (value == null) {
continue;
}
Matcher matcher = ID_CARD.matcher(value.toString());
while (matcher.find()) {
matches.add(matcher.group(1).toUpperCase(Locale.ROOT));
}
}
return matches.size() == 1 ? Optional.of(matches.iterator().next()) : Optional.empty();
}
}
package com.zhihui.portal.cert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.time.Duration;
import java.util.Base64;
@Component
public class GatewayMessageClient {
private static final Logger log = LoggerFactory.getLogger(GatewayMessageClient.class);
private static final String CERT_NAMESPACE =
"http://www.jit.com.cn/cinas/ias/ns/saml/saml11/X.509";
private static final String SUBJECT_DN_ATTRIBUTE = "X509Certificate.SubjectDN";
private final CertificateAuthProperties properties;
private final URI gatewayUri;
private final HttpClient httpClient;
private final String configError;
public GatewayMessageClient(CertificateAuthProperties properties) {
this.properties = properties;
URI uri = null;
HttpClient client = null;
String error = null;
if (!properties.isEnabled()) {
error = "数字证书登录未启用";
} else if (!StringUtils.hasText(properties.getGatewayUrl())
|| !StringUtils.hasText(properties.getAppId())) {
error = "网关地址或应用标识未配置";
} else {
try {
uri = URI.create(properties.getGatewayUrl().trim());
validateUri(uri, properties.isAllowInsecureHttp());
client = buildHttpClient(properties, uri);
if ("http".equalsIgnoreCase(uri.getScheme())) {
log.warn("证书认证网关使用 HTTP 通信,生产环境建议改为 HTTPS");
}
} catch (Exception ex) {
uri = null;
client = null;
error = "网关配置无效";
log.warn("证书认证网关配置无效: {}", ex.getMessage());
}
}
this.gatewayUri = uri;
this.httpClient = client;
this.configError = error;
}
public boolean isAvailable() {
return configError == null;
}
public GatewayAuthResult authenticate(String clientIp, String original, String signedData) {
if (!isAvailable()) {
throw gatewayUnavailable();
}
String signature = signedData == null ? "" : signedData.trim();
if (!StringUtils.hasText(original) || !validSignature(signature)) {
throw certInvalid("证书认证数据不完整");
}
final byte[] payload;
try {
payload = buildAuthRequest(clientIp, original, signature);
} catch (Exception ex) {
log.error("组装证书认证请求失败: {}", ex.getClass().getSimpleName());
throw gatewayUnavailable();
}
HttpRequest request = HttpRequest.newBuilder(gatewayUri)
.timeout(requestTimeout())
.header("Content-Type", "text/xml;charset=UTF-8")
.header("Accept", "text/xml")
.POST(HttpRequest.BodyPublishers.ofByteArray(payload))
.build();
final HttpResponse<InputStream> response;
try {
response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw gatewayUnavailable();
} catch (Exception ex) {
log.warn("调用证书认证网关失败: {}", ex.getClass().getSimpleName());
throw gatewayUnavailable();
}
final byte[] body;
try (InputStream input = response.body()) {
body = readLimited(input, maxResponseBytes());
} catch (IOException ex) {
log.warn("读取证书认证网关响应失败: {}", ex.getClass().getSimpleName());
throw gatewayRejected("网关响应读取失败");
}
int status = response.statusCode();
if (status == 500) {
return parseFailureResponse(body, "网关返回 HTTP 500");
}
if (status != 200) {
throw gatewayRejected("网关返回 HTTP " + status);
}
try {
return parseResponse(body);
} catch (Exception ex) {
log.warn("解析证书认证网关响应失败: {}", ex.getClass().getSimpleName());
throw gatewayRejected("网关响应解析失败");
}
}
private boolean validSignature(String signature) {
int max = properties.getMaxSignedDataBytes() > 0
? properties.getMaxSignedDataBytes() : 512 * 1024;
return signature.length() <= max
&& signature.matches("[A-Za-z0-9+/=_\\-\\s]+");
}
private byte[] buildAuthRequest(String clientIp, String original, String signature) throws Exception {
Document document = secureDocumentBuilderFactory().newDocumentBuilder().newDocument();
Element message = document.createElement("message");
document.appendChild(message);
Element head = child(message, "head", true);
appendText(head, "version", "1.0");
appendText(head, "serviceType", "AuthenService");
Element body = child(message, "body", true);
Element clientInfo = child(body, "clientInfo", true);
appendText(clientInfo, "clientIP", StringUtils.hasText(clientIp) ? clientIp.trim() : "unknown");
appendText(body, "appId", properties.getAppId().trim());
Element authen = child(body, "authen", true);
Element credential = child(authen, "authCredential", true);
credential.setAttribute("authMode", "cert");
appendText(credential, "detach", signature);
appendText(credential, "original",
Base64.getEncoder().encodeToString(original.getBytes(StandardCharsets.UTF_8)));
appendText(body, "accessControl", String.valueOf(properties.isAccessControl()));
Element attributes = child(body, "attributes", true);
if ("all".equalsIgnoreCase(properties.getAttributesMode())) {
attributes.setAttribute("attributeType", "all");
} else {
attributes.setAttribute("attributeType", "portion");
Element attr = child(attributes, "attr", true);
attr.setAttribute("name", SUBJECT_DN_ATTRIBUTE);
attr.setAttribute("namespace", CERT_NAMESPACE);
}
TransformerFactory factory = TransformerFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
Transformer transformer = factory.newTransformer();
transformer.setOutputProperty(OutputKeys.ENCODING, StandardCharsets.UTF_8.name());
transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
ByteArrayOutputStream output = new ByteArrayOutputStream();
transformer.transform(new DOMSource(document), new StreamResult(output));
return output.toByteArray();
}
private GatewayAuthResult parseResponse(byte[] body) throws Exception {
Document document = secureDocumentBuilderFactory().newDocumentBuilder()
.parse(new ByteArrayInputStream(repairUnescapedAmpersands(body)));
Element root = document.getDocumentElement();
if (root == null || !"message".equals(root.getNodeName())) {
return GatewayAuthResult.failure("INVALID_RESPONSE", "网关响应根节点无效");
}
Element head = child(root, "head", false);
if (head != null && "true".equalsIgnoreCase(text(head, "messageState"))) {
return GatewayAuthResult.failure(text(head, "messageCode"), text(head, "messageDesc"));
}
Element responseBody = child(root, "body", false);
if (responseBody == null) {
return GatewayAuthResult.failure("INVALID_RESPONSE", "网关响应缺少报文体");
}
Element resultSet = child(responseBody, "authResultSet", false);
Element certResult = findCertResult(resultSet);
if (certResult == null) {
return GatewayAuthResult.failure("NO_CERT_RESULT", "网关未返回证书认证结果");
}
if (!"true".equalsIgnoreCase(certResult.getAttribute("success"))) {
return GatewayAuthResult.failure(text(certResult, "authMessageCode"),
text(certResult, "authMessageDesc"));
}
Element accessControl = child(responseBody, "accessControlResult", false);
if (accessControl != null
&& "Deny".equalsIgnoreCase(accessControl.getTextContent().trim())) {
return GatewayAuthResult.failure("ACCESS_DENIED", "用户无权限访问此应用");
}
String subjectDn = findSubjectDn(responseBody);
if (!StringUtils.hasText(subjectDn)) {
return GatewayAuthResult.failure("NO_SUBJECT_DN", "网关未返回证书主题");
}
return GatewayAuthResult.success(subjectDn);
}
private GatewayAuthResult parseFailureResponse(byte[] body, String fallbackMessage) {
try {
Document document = secureDocumentBuilderFactory().newDocumentBuilder()
.parse(new ByteArrayInputStream(repairUnescapedAmpersands(body)));
Element head = child(document.getDocumentElement(), "head", false);
if (head != null) {
return GatewayAuthResult.failure(text(head, "messageCode"), text(head, "messageDesc"));
}
} catch (Exception ignored) {
// 网关 500 响应不一定是 XML,按状态码处理
}
return GatewayAuthResult.failure("HTTP_500", fallbackMessage);
}
private String findSubjectDn(Element body) {
Element attributes = child(body, "attributes", false);
if (attributes == null) {
return null;
}
NodeList nodes = attributes.getElementsByTagName("attr");
for (int i = 0; i < nodes.getLength(); i++) {
Element attr = (Element) nodes.item(i);
String name = attr.getAttribute("name");
if (SUBJECT_DN_ATTRIBUTE.equals(name) || "dnname".equalsIgnoreCase(name)) {
String value = attr.getTextContent();
if (StringUtils.hasText(value)) {
return value.trim();
}
}
}
return null;
}
private Element findCertResult(Element resultSet) {
if (resultSet == null) {
return null;
}
NodeList nodes = resultSet.getElementsByTagName("authResult");
for (int i = 0; i < nodes.getLength(); i++) {
Element result = (Element) nodes.item(i);
if ("cert".equalsIgnoreCase(result.getAttribute("authMode"))) {
return result;
}
}
return null;
}
private static Element child(Element parent, String name, boolean create) {
Node node = parent.getFirstChild();
while (node != null) {
if (node.getNodeType() == Node.ELEMENT_NODE && name.equals(node.getNodeName())) {
return (Element) node;
}
node = node.getNextSibling();
}
if (!create) {
return null;
}
Element element = parent.getOwnerDocument().createElement(name);
parent.appendChild(element);
return element;
}
private static void appendText(Element parent, String name, String value) {
Element element = parent.getOwnerDocument().createElement(name);
element.setTextContent(value == null ? "" : value);
parent.appendChild(element);
}
private static String text(Element parent, String name) {
Element element = parent == null ? null : child(parent, name, false);
return element == null ? null : element.getTextContent().trim();
}
private static DocumentBuilderFactory secureDocumentBuilderFactory()
throws ParserConfigurationException {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
return factory;
}
private static byte[] readLimited(InputStream input, int maxBytes) throws IOException {
int limit = maxBytes > 0 ? maxBytes : 256 * 1024;
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
int total = 0;
int read;
while ((read = input.read(buffer)) != -1) {
total += read;
if (total > limit) {
throw new IOException("响应超过大小限制");
}
output.write(buffer, 0, read);
}
return output.toByteArray();
}
/**
* 厂商网关在证书主题包含 & 时不保证转义;只补齐未转义的 &,不改动已有实体。
*/
private static byte[] repairUnescapedAmpersands(byte[] body) {
String xml = new String(body, StandardCharsets.UTF_8);
String repaired = xml.replaceAll(
"&(?!(?:amp|lt|gt|quot|apos|#\\d+|#x[0-9A-Fa-f]+);)", "&amp;");
return repaired.equals(xml) ? body : repaired.getBytes(StandardCharsets.UTF_8);
}
private Duration requestTimeout() {
Duration timeout = properties.getRequestTimeout();
return timeout == null ? Duration.ofSeconds(15) : timeout;
}
private int maxResponseBytes() {
return properties.getMaxResponseBytes() > 0
? properties.getMaxResponseBytes() : 256 * 1024;
}
private static void validateUri(URI uri, boolean allowInsecureHttp) {
String scheme = uri.getScheme();
if (scheme == null || (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))) {
throw new IllegalArgumentException("网关地址必须是 http 或 https 地址");
}
if (!StringUtils.hasText(uri.getHost())) {
throw new IllegalArgumentException("网关地址缺少主机名");
}
if ("http".equalsIgnoreCase(scheme) && !allowInsecureHttp) {
throw new IllegalArgumentException("未允许使用明文 HTTP 访问网关");
}
}
private static HttpClient buildHttpClient(CertificateAuthProperties properties, URI uri)
throws Exception {
HttpClient.Builder builder = HttpClient.newBuilder()
.connectTimeout(properties.getConnectTimeout() == null
? Duration.ofSeconds(5) : properties.getConnectTimeout())
.followRedirects(HttpClient.Redirect.NEVER)
.version(HttpClient.Version.HTTP_1_1);
if ("https".equalsIgnoreCase(uri.getScheme())
&& StringUtils.hasText(properties.getTrustStore())) {
builder.sslContext(buildSslContext(properties));
}
return builder.build();
}
private static SSLContext buildSslContext(CertificateAuthProperties properties) throws Exception {
String storePath = properties.getTrustStore().trim();
Path path = storePath.startsWith("file:")
? Path.of(URI.create(storePath)) : Path.of(storePath);
KeyStore keyStore = KeyStore.getInstance(
StringUtils.hasText(properties.getTrustStoreType())
? properties.getTrustStoreType() : "PKCS12");
char[] password = properties.getTrustStorePassword() == null
? new char[0] : properties.getTrustStorePassword().toCharArray();
try (InputStream input = Files.newInputStream(path)) {
keyStore.load(input, password);
}
TrustManagerFactory factory = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm());
factory.init(keyStore);
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, factory.getTrustManagers(), null);
return context;
}
private static CertificateAuthException gatewayUnavailable() {
return new CertificateAuthException(
CertificateAuthException.Reason.GATEWAY_UNAVAILABLE,
"证书认证服务暂不可用",
"证书认证失败(网关不可用)",
"证书用户",
true,
"cert_unavailable");
}
private static CertificateAuthException gatewayRejected(String detail) {
return new CertificateAuthException(
CertificateAuthException.Reason.GATEWAY_REJECTED,
"证书认证失败",
"证书认证失败(" + detail + ")",
"证书用户",
true,
"cert_failed");
}
private static CertificateAuthException certInvalid(String detail) {
return new CertificateAuthException(
CertificateAuthException.Reason.CERT_INVALID,
"证书认证数据无效",
"证书认证失败(" + detail + ")",
"证书用户",
true,
"cert_failed");
}
public record GatewayAuthResult(boolean success, String subjectDn, String errorCode, String errorMessage) {
public static GatewayAuthResult success(String subjectDn) {
return new GatewayAuthResult(true, subjectDn, null, null);
}
public static GatewayAuthResult failure(String errorCode, String errorMessage) {
return new GatewayAuthResult(false, null, errorCode, errorMessage);
}
}
}
......@@ -3,8 +3,17 @@ package com.zhihui.portal.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import com.zhihui.portal.app.AppAuthorizationFilter;
import com.zhihui.portal.app.PortalAppRepository;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.PortalAccounts;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.security.ActiveUserFilter;
import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.LoginIpBindingFilter;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import com.zhihui.portal.security.SessionRevocationService;
import com.zhihui.portal.user.SysUserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
......@@ -29,6 +38,7 @@ import org.springframework.security.oauth2.server.authorization.token.JwtEncodin
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import java.util.ArrayList;
......@@ -49,16 +59,34 @@ public class AuthorizationServerConfig {
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
public SecurityFilterChain authorizationServerSecurityFilterChain(
HttpSecurity http,
SysUserRepository userRepository,
PortalAppRepository appRepository,
JdbcTemplate jdbcTemplate,
AuditLogService auditLogService,
ClientInfoResolver clientInfoResolver,
SessionRevocationService sessionRevocationService,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
OAuth2AuthorizationServerConfigurer.authorizationServer();
ActiveUserFilter activeUserFilter = new ActiveUserFilter(userRepository);
LoginIpBindingFilter loginIpBindingFilter = new LoginIpBindingFilter(
clientInfoResolver, auditLogService, sessionRevocationService,
oauthAuthorizationRevocationService);
AppAuthorizationFilter appAuthorizationFilter =
new AppAuthorizationFilter(appRepository, jdbcTemplate);
http
.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
.with(authorizationServerConfigurer, authorizationServer ->
authorizationServer.oidc(oidc -> oidc.userInfoEndpoint(userInfo ->
userInfo.userInfoMapper(AuthorizationServerConfig::mapUserInfo))))
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.addFilterAfter(loginIpBindingFilter, SecurityContextHolderFilter.class)
.addFilterAfter(activeUserFilter, SecurityContextHolderFilter.class)
.addFilterAfter(appAuthorizationFilter, SecurityContextHolderFilter.class)
.exceptionHandling(exceptions -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
......
package com.zhihui.portal.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.filter.ForwardedHeaderFilter;
/**
* 只有显式信任反向代理时才解析 Forwarded / X-Forwarded-*,
* 避免直连部署时客户端伪造来源 IP 绕过登录 IP 绑定。
*/
@Configuration
public class ForwardedHeaderConfig {
@Bean
@ConditionalOnProperty(prefix = "zhihui.security", name = "trust-forwarded-headers",
havingValue = "true")
public FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilter() {
FilterRegistrationBean<ForwardedHeaderFilter> registration =
new FilterRegistrationBean<>(new ForwardedHeaderFilter());
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
return registration;
}
}
......@@ -5,12 +5,21 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.cert.CertificateAuthService;
import com.zhihui.portal.cert.CertificateAuthenticationFilter;
import com.zhihui.portal.cert.CertificateLoginFailureHandler;
import com.zhihui.portal.security.ActiveUserFilter;
import com.zhihui.portal.security.AuditLogoutSuccessHandler;
import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.LoginAttemptService;
import com.zhihui.portal.security.LoginFailureHandler;
import com.zhihui.portal.security.LoginIpBindingFilter;
import com.zhihui.portal.security.LoginRateLimitFilter;
import com.zhihui.portal.security.LoginSuccessHandler;
import com.zhihui.portal.security.MustChangePasswordFilter;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import com.zhihui.portal.security.SessionRevocationService;
import com.zhihui.portal.user.SysUserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
......@@ -21,6 +30,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.security.web.csrf.CsrfToken;
......@@ -51,25 +61,43 @@ public class SecurityConfig {
LoginAttemptService loginAttemptService,
ClientInfoResolver clientInfoResolver,
AuditLogService auditLogService,
PortalSecurityProperties securityProperties)
PortalSecurityProperties securityProperties,
SysUserRepository userRepository,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService,
SessionRevocationService sessionRevocationService,
CertificateAuthService certificateAuthService)
throws Exception {
RequestMatcher apiMatcher = PathPatternRequestMatcher.withDefaults().matcher("/api/**");
LoginRateLimitFilter rateLimitFilter = new LoginRateLimitFilter(
loginAttemptService, clientInfoResolver, auditLogService, securityProperties);
LoginSuccessHandler loginSuccessHandler = new LoginSuccessHandler(
loginAttemptService, clientInfoResolver, auditLogService);
loginAttemptService, clientInfoResolver, auditLogService, userRepository);
LoginFailureHandler loginFailureHandler = new LoginFailureHandler(
loginAttemptService, clientInfoResolver, auditLogService);
CertificateLoginFailureHandler certificateLoginFailureHandler =
new CertificateLoginFailureHandler(
loginAttemptService, clientInfoResolver, auditLogService);
CertificateAuthenticationFilter certificateAuthenticationFilter =
new CertificateAuthenticationFilter(
certificateAuthService, loginSuccessHandler, certificateLoginFailureHandler,
clientInfoResolver, loginAttemptService, auditLogService);
AuditLogoutSuccessHandler logoutSuccessHandler = new AuditLogoutSuccessHandler(
auditLogService, clientInfoResolver);
auditLogService, clientInfoResolver, oauthAuthorizationRevocationService);
LoginIpBindingFilter loginIpBindingFilter = new LoginIpBindingFilter(
clientInfoResolver, auditLogService, sessionRevocationService,
oauthAuthorizationRevocationService);
ActiveUserFilter activeUserFilter = new ActiveUserFilter(userRepository);
MustChangePasswordFilter mustChangePasswordFilter = new MustChangePasswordFilter();
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/login", "/css/**", "/img/**", "/spa/**", "/favicon.ico", "/error").permitAll()
.requestMatchers("/login", "/login/cert/**", "/css/**", "/img/**", "/spa/**",
"/favicon.ico", "/error").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/actuator/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.requestMatchers("/change-password").authenticated()
.anyRequest().permitAll())
.formLogin(form -> form
.loginPage("/login")
......@@ -86,7 +114,11 @@ public class SecurityConfig {
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
.addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class)
.addFilterAfter(loginIpBindingFilter, SecurityContextHolderFilter.class)
.addFilterAfter(activeUserFilter, SecurityContextHolderFilter.class)
.addFilterAfter(mustChangePasswordFilter, SecurityContextHolderFilter.class)
.addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(certificateAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.sessionManagement(session -> session
.sessionFixation(fixation -> fixation.migrateSession()))
.exceptionHandling(exceptions -> exceptions
......
......@@ -11,6 +11,7 @@ import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.config.ZhihuiProperties;
import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import com.zhihui.portal.user.SysUser;
import com.zhihui.portal.user.UserService;
import com.zhihui.portal.user.dto.UserDtos.UpdateEmailRequest;
......@@ -40,15 +41,18 @@ public class PortalController {
private final AuditLogService auditLogService;
private final ClientInfoResolver clientInfoResolver;
private final UserService userService;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public PortalController(AppService appService, ZhihuiProperties properties,
AuditLogService auditLogService, ClientInfoResolver clientInfoResolver,
UserService userService) {
UserService userService,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.appService = appService;
this.properties = properties;
this.auditLogService = auditLogService;
this.clientInfoResolver = clientInfoResolver;
this.userService = userService;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
}
@GetMapping("/me")
......@@ -113,6 +117,9 @@ public class PortalController {
auditLogService.recordLogout(user,
clientInfoResolver.resolveIp(request),
clientInfoResolver.resolveUserAgent(request));
if (user != null) {
oauthAuthorizationRevocationService.revokeByPrincipalName(user.getUsername());
}
new SecurityContextLogoutHandler().logout(request, response, authentication);
}
......
package com.zhihui.portal.security;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.user.SysUser;
import com.zhihui.portal.user.SysUserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Objects;
/**
* 每次受保护请求都从数据库刷新账号状态,避免降级、停用或删除后的旧会话继续生效。
*/
public class ActiveUserFilter extends OncePerRequestFilter {
private static final RequestMatcher API_MATCHER =
PathPatternRequestMatcher.withDefaults().matcher("/api/**");
private static final RequestMatcher AUTHORIZE_MATCHER =
PathPatternRequestMatcher.withDefaults().matcher("/oauth2/authorize");
private static final RequestMatcher LOGOUT_MATCHER =
PathPatternRequestMatcher.withDefaults().matcher("/api/logout");
private final SysUserRepository userRepository;
public ActiveUserFilter(SysUserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
if (LOGOUT_MATCHER.matches(request)) {
return true;
}
return !API_MATCHER.matches(request) && !AUTHORIZE_MATCHER.matches(request);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null
|| !(authentication.getPrincipal() instanceof SecurityUser currentUser)) {
filterChain.doFilter(request, response);
return;
}
SysUser current = userRepository.findById(currentUser.getId()).orElse(null);
if (current == null || current.getStatus() == null || current.getStatus() != 1) {
SecurityContextHolder.clearContext();
HttpSession session = request.getSession(false);
if (session != null) {
try {
session.invalidate();
} catch (IllegalStateException ignored) {
// 会话已经失效时忽略
}
}
writeInactive(request, response);
return;
}
if (!sameIdentity(currentUser, current)) {
SecurityUser refreshed = new SecurityUser(
current.getId(),
current.getUsername(),
null,
current.getDisplayName(),
current.getPoliceNumber(),
current.getIdCardNumber(),
current.getPhone(),
current.getEmail(),
current.getRole(),
true,
current.isMustChangePassword());
UsernamePasswordAuthenticationToken replacement = new UsernamePasswordAuthenticationToken(
refreshed, null, refreshed.getAuthorities());
replacement.setDetails(authentication.getDetails());
SecurityContextHolder.getContext().setAuthentication(replacement);
}
filterChain.doFilter(request, response);
}
private static boolean sameIdentity(SecurityUser sessionUser, SysUser current) {
return Objects.equals(sessionUser.getUsername(), current.getUsername())
&& Objects.equals(sessionUser.getRole(), current.getRole())
&& Objects.equals(sessionUser.getDisplayName(), current.getDisplayName())
&& Objects.equals(sessionUser.getPoliceNumber(), current.getPoliceNumber())
&& Objects.equals(sessionUser.getIdCardNumber(), current.getIdCardNumber())
&& Objects.equals(sessionUser.getPhoneNumber(), current.getPhone())
&& Objects.equals(sessionUser.getEmail(), current.getEmail())
&& sessionUser.isMustChangePassword() == current.isMustChangePassword();
}
private static void writeInactive(HttpServletRequest request, HttpServletResponse response)
throws IOException {
if (API_MATCHER.matches(request)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\":\"账号已停用或不存在\"}");
return;
}
response.sendRedirect(request.getContextPath() + "/login?error=disabled");
}
}
......@@ -14,11 +14,14 @@ public class AuditLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
private final AuditLogService auditLogService;
private final ClientInfoResolver clientInfoResolver;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public AuditLogoutSuccessHandler(AuditLogService auditLogService,
ClientInfoResolver clientInfoResolver) {
ClientInfoResolver clientInfoResolver,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.auditLogService = auditLogService;
this.clientInfoResolver = clientInfoResolver;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
setDefaultTargetUrl("/login?logout");
}
......@@ -27,6 +30,9 @@ public class AuditLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
Authentication authentication) throws IOException, ServletException {
SecurityUser user = authentication != null
&& authentication.getPrincipal() instanceof SecurityUser value ? value : null;
if (user != null) {
oauthAuthorizationRevocationService.revokeByPrincipalName(user.getUsername());
}
auditLogService.recordLogout(user,
clientInfoResolver.resolveIp(request),
clientInfoResolver.resolveUserAgent(request));
......
......@@ -36,7 +36,7 @@ public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
String userAgent = clientInfoResolver.resolveUserAgent(request);
LoginAttemptService.Decision decision = loginAttemptService.recordFailure(username, clientIp);
auditLogService.recordLoginFailure(username, clientIp, userAgent,
reason(exception), decision.blocked());
reason(exception), decision.blocked(), LoginType.PASSWORD);
if (decision.blocked()) {
response.setHeader("Retry-After", String.valueOf(decision.retryAfterSeconds()));
......
package com.zhihui.portal.security;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 登录会话与首次登录 IP 绑定。IP 变化视为认证信息可能被窃用,
* 立即撤销当前会话、该用户全部门户会话及全部子系统授权。
*/
public class LoginIpBindingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(LoginIpBindingFilter.class);
private static final RequestMatcher API_MATCHER =
PathPatternRequestMatcher.withDefaults().matcher("/api/**");
private final ClientInfoResolver clientInfoResolver;
private final AuditLogService auditLogService;
private final SessionRevocationService sessionRevocationService;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public LoginIpBindingFilter(ClientInfoResolver clientInfoResolver,
AuditLogService auditLogService,
SessionRevocationService sessionRevocationService,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.clientInfoResolver = clientInfoResolver;
this.auditLogService = auditLogService;
this.sessionRevocationService = sessionRevocationService;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null
|| !(authentication.getPrincipal() instanceof SecurityUser user)) {
filterChain.doFilter(request, response);
return;
}
HttpSession session = request.getSession(false);
String currentIp = clientInfoResolver.resolveIp(request);
if (!StringUtils.hasText(currentIp)) {
currentIp = "unknown";
}
if (session == null) {
SecurityContextHolder.clearContext();
deny(request, response);
return;
}
String boundIp = attribute(session, LoginSession.IP_ATTRIBUTE);
LoginType loginType = LoginType.from(attribute(session, LoginSession.TYPE_ATTRIBUTE));
if (!StringUtils.hasText(boundIp)) {
auditLogService.recordLoginIpChanged(user, null, currentIp,
clientInfoResolver.resolveUserAgent(request), loginType);
invalidate(session);
SecurityContextHolder.clearContext();
deny(request, response);
return;
}
if (boundIp.equals(currentIp)) {
filterChain.doFilter(request, response);
return;
}
log.warn("登录IP变更,撤销认证信息: username={}, boundIp={}, currentIp={}",
user.getUsername(), boundIp, currentIp);
auditLogService.recordLoginIpChanged(user, boundIp, currentIp,
clientInfoResolver.resolveUserAgent(request), loginType);
invalidate(session);
sessionRevocationService.revokeByUsername(user.getUsername());
oauthAuthorizationRevocationService.revokeByPrincipalName(user.getUsername());
SecurityContextHolder.clearContext();
deny(request, response);
}
private static String attribute(HttpSession session, String name) {
Object value = session.getAttribute(name);
return value instanceof String text ? text : null;
}
private static void invalidate(HttpSession session) {
try {
session.invalidate();
} catch (IllegalStateException ignored) {
// 会话已经失效时忽略
}
}
private static void deny(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.setHeader("Cache-Control", "no-store");
if (API_MATCHER.matches(request)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"code\":\"LOGIN_IP_CHANGED\",\"message\":\"登录IP已变更,请重新登录\"}");
return;
}
response.sendRedirect(request.getContextPath() + "/login?error=ip_changed");
}
}
......@@ -51,7 +51,7 @@ public class LoginRateLimitFilter extends OncePerRequestFilter {
auditLogService.recordLoginBlocked(username, clientIp,
clientInfoResolver.resolveUserAgent(request),
decision.scope(), decision.retryAfterSeconds());
decision.scope(), decision.retryAfterSeconds(), LoginType.PASSWORD);
response.setHeader("Retry-After", String.valueOf(decision.retryAfterSeconds()));
response.sendRedirect(request.getContextPath() + "/login?error=locked");
}
......
package com.zhihui.portal.security;
public final class LoginSession {
public static final String IP_ATTRIBUTE = "ZHIHUI_LOGIN_IP";
public static final String TYPE_ATTRIBUTE = "ZHIHUI_LOGIN_TYPE";
public static final String TIME_ATTRIBUTE = "ZHIHUI_LOGIN_TIME";
private LoginSession() {
}
}
......@@ -2,26 +2,37 @@ package com.zhihui.portal.security;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.user.SysUserRepository;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
public static final String LOGIN_DETAIL_ATTRIBUTE = "zhihui.login.detail";
public static final String LOGIN_TYPE_ATTRIBUTE = "zhihui.login.type";
private final LoginAttemptService loginAttemptService;
private final ClientInfoResolver clientInfoResolver;
private final AuditLogService auditLogService;
private final SysUserRepository userRepository;
public LoginSuccessHandler(LoginAttemptService loginAttemptService,
ClientInfoResolver clientInfoResolver,
AuditLogService auditLogService) {
AuditLogService auditLogService,
SysUserRepository userRepository) {
this.loginAttemptService = loginAttemptService;
this.clientInfoResolver = clientInfoResolver;
this.auditLogService = auditLogService;
this.userRepository = userRepository;
setDefaultTargetUrl("/");
setAlwaysUseDefaultTargetUrl(true);
}
......@@ -30,11 +41,30 @@ public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws ServletException, IOException {
String clientIp = clientInfoResolver.resolveIp(request);
String boundIp = StringUtils.hasText(clientIp) ? clientIp : "unknown";
loginAttemptService.reset(request.getParameter("username"), clientIp);
loginAttemptService.reset(authentication.getName(), clientIp);
SecurityUser user = authentication.getPrincipal() instanceof SecurityUser value ? value : null;
LoginType loginType = LoginType.from(request.getAttribute(LOGIN_TYPE_ATTRIBUTE) instanceof String value
? value : null);
String detail = request.getAttribute(LOGIN_DETAIL_ATTRIBUTE) instanceof String value
&& !value.isBlank() ? value : "登录成功(" + loginType.getLabel() + ")";
HttpSession session = request.getSession(true);
session.setAttribute(LoginSession.IP_ATTRIBUTE, boundIp);
session.setAttribute(LoginSession.TYPE_ATTRIBUTE, loginType.name());
session.setAttribute(LoginSession.TIME_ATTRIBUTE, System.currentTimeMillis());
auditLogService.recordLoginSuccess(user, clientIp,
clientInfoResolver.resolveUserAgent(request));
clientInfoResolver.resolveUserAgent(request), detail, loginType);
if (user != null) {
userRepository.updateLastLoginAt(user.getId(), LocalDateTime.now(ZoneOffset.UTC));
if (user.isMustChangePassword()) {
getRedirectStrategy().sendRedirect(request, response,
request.getContextPath() + "/change-password");
return;
}
}
super.onAuthenticationSuccess(request, response, authentication);
}
}
package com.zhihui.portal.security;
import java.util.Locale;
public enum LoginType {
PASSWORD("账号密码"),
CERTIFICATE("数字证书");
private final String label;
LoginType(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
public static LoginType from(String value) {
if (value != null && "CERTIFICATE".equals(value.trim().toUpperCase(Locale.ROOT))) {
return CERTIFICATE;
}
return PASSWORD;
}
}
package com.zhihui.portal.security;
import com.zhihui.portal.auth.SecurityUser;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* 导入账号首次登录后,在修改密码前只允许访问改密页和退出登录。
*/
public class MustChangePasswordFilter extends OncePerRequestFilter {
private static final RequestMatcher API_MATCHER =
PathPatternRequestMatcher.withDefaults().matcher("/api/**");
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null
|| !(authentication.getPrincipal() instanceof SecurityUser user)
|| !user.isMustChangePassword()) {
return true;
}
String path = request.getRequestURI().substring(request.getContextPath().length());
return path.equals("/change-password")
|| path.equals("/logout")
|| path.startsWith("/css/")
|| path.startsWith("/js/")
|| path.startsWith("/img/")
|| path.equals("/favicon.ico")
|| path.equals("/error");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (API_MATCHER.matches(request)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\":\"请先修改初始密码\"}");
return;
}
response.sendRedirect(request.getContextPath() + "/change-password");
}
}
package com.zhihui.portal.security;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class OAuthAuthorizationRevocationService {
private final JdbcTemplate jdbcTemplate;
public OAuthAuthorizationRevocationService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void revokeByPrincipalName(String username) {
if (!StringUtils.hasText(username)) {
return;
}
jdbcTemplate.update("DELETE FROM oauth2_authorization WHERE principal_name = ?", username);
jdbcTemplate.update("DELETE FROM oauth2_authorization_consent WHERE principal_name = ?", username);
}
public void revokeByRegisteredClientId(String registeredClientId) {
if (!StringUtils.hasText(registeredClientId)) {
return;
}
jdbcTemplate.update("DELETE FROM oauth2_authorization WHERE registered_client_id = ?", registeredClientId);
jdbcTemplate.update(
"DELETE FROM oauth2_authorization_consent WHERE registered_client_id = ?",
registeredClientId);
}
public void revokeByPrincipalAndClientId(String username, String clientId) {
if (!StringUtils.hasText(username) || !StringUtils.hasText(clientId)) {
return;
}
jdbcTemplate.update("""
DELETE FROM oauth2_authorization
WHERE principal_name = ?
AND registered_client_id = (
SELECT id FROM oauth2_registered_client WHERE client_id = ?
)
""", username, clientId);
jdbcTemplate.update("""
DELETE FROM oauth2_authorization_consent
WHERE principal_name = ?
AND registered_client_id = (
SELECT id FROM oauth2_registered_client WHERE client_id = ?
)
""", username, clientId);
}
}
package com.zhihui.portal.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.Session;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class SessionRevocationService {
private static final Logger log = LoggerFactory.getLogger(SessionRevocationService.class);
private final ObjectProvider<FindByIndexNameSessionRepository<? extends Session>> repositoryProvider;
public SessionRevocationService(
ObjectProvider<FindByIndexNameSessionRepository<? extends Session>> repositoryProvider) {
this.repositoryProvider = repositoryProvider;
}
public void revokeByUsername(String username) {
if (!StringUtils.hasText(username)) {
return;
}
FindByIndexNameSessionRepository<? extends Session> repository = repositoryProvider.getIfAvailable();
if (repository == null) {
log.warn("未找到 Spring Session 索引仓库,无法撤销用户会话: username={}", username);
return;
}
try {
repository.findByPrincipalName(username).keySet().forEach(repository::deleteById);
} catch (RuntimeException ex) {
log.warn("撤销用户会话失败: username={}, error={}", username, ex.getMessage());
}
}
}
package com.zhihui.portal.user;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
@Component
public class AccountInactivityScheduler {
private final AccountInactivityService accountInactivityService;
public AccountInactivityScheduler(AccountInactivityService accountInactivityService) {
this.accountInactivityService = accountInactivityService;
}
@Scheduled(cron = "0 17 3 * * *", zone = "Asia/Shanghai")
public void disableInactiveAccounts() {
accountInactivityService.disableInactive(LocalDateTime.now(ZoneOffset.UTC));
}
}
package com.zhihui.portal.user;
import com.zhihui.portal.audit.AuditEvents;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import com.zhihui.portal.security.SessionRevocationService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
public class AccountInactivityService {
static final int IMPORTED_INACTIVE_DAYS = 30;
static final int ALL_INACTIVE_DAYS = 90;
private final SysUserRepository userRepository;
private final AuditLogService auditLogService;
private final SessionRevocationService sessionRevocationService;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public AccountInactivityService(SysUserRepository userRepository,
AuditLogService auditLogService,
SessionRevocationService sessionRevocationService,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.userRepository = userRepository;
this.auditLogService = auditLogService;
this.sessionRevocationService = sessionRevocationService;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
}
@Transactional
public InactivityResult disableInactive(LocalDateTime now) {
List<SysUser> imported = userRepository.findInactiveImported(
now.minusDays(IMPORTED_INACTIVE_DAYS));
List<SysUser> all = userRepository.findInactiveExceptAdmin(
now.minusDays(ALL_INACTIVE_DAYS));
Set<Long> disabledIds = new HashSet<>();
int importedDisabled = disable(imported, "导入后30天未登录", disabledIds);
int generalDisabled = disable(all, "90天未使用", disabledIds);
return new InactivityResult(importedDisabled, generalDisabled);
}
private int disable(List<SysUser> users, String reason, Set<Long> disabledIds) {
int count = 0;
for (SysUser user : users) {
if (!disabledIds.add(user.getId())) {
continue;
}
user.setStatus(0);
userRepository.save(user);
sessionRevocationService.revokeByUsername(user.getUsername());
oauthAuthorizationRevocationService.revokeByPrincipalName(user.getUsername());
auditLogService.recordSystem(AuditEvents.USER_AUTO_DISABLE, "USER",
String.valueOf(user.getId()),
"username=" + user.getUsername() + "; reason=" + reason);
count++;
}
return count;
}
public record InactivityResult(int importedDisabled, int generalDisabled) {
}
}
......@@ -44,6 +44,15 @@ public class SysUser {
@Column(name = "status", nullable = false)
private Integer status = 1;
@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;
@Column(name = "must_change_password", nullable = false)
private boolean mustChangePassword = false;
@Column(name = "imported", nullable = false)
private boolean imported = false;
@Column(name = "created_at", insertable = false, updatable = false)
private LocalDateTime createdAt;
......@@ -130,6 +139,30 @@ public class SysUser {
this.status = status;
}
public LocalDateTime getLastLoginAt() {
return lastLoginAt;
}
public void setLastLoginAt(LocalDateTime lastLoginAt) {
this.lastLoginAt = lastLoginAt;
}
public boolean isMustChangePassword() {
return mustChangePassword;
}
public void setMustChangePassword(boolean mustChangePassword) {
this.mustChangePassword = mustChangePassword;
}
public boolean isImported() {
return imported;
}
public void setImported(boolean imported) {
this.imported = imported;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
......
......@@ -3,9 +3,13 @@ package com.zhihui.portal.user;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface SysUserRepository extends JpaRepository<SysUser, Long> {
......@@ -44,4 +48,27 @@ public interface SysUserRepository extends JpaRepository<SysUser, Long> {
or u.phone like concat('%', :kw, '%')
""")
Page<SysUser> search(@Param("kw") String keyword, Pageable pageable);
@Modifying
@Transactional
@Query("update SysUser u set u.lastLoginAt = :lastLoginAt where u.id = :id")
int updateLastLoginAt(@Param("id") Long id, @Param("lastLoginAt") LocalDateTime lastLoginAt);
@Query("""
select u from SysUser u
where u.status = 1
and u.imported = true
and u.lastLoginAt is null
and lower(u.username) <> 'admin'
and u.createdAt < :cutoff
""")
List<SysUser> findInactiveImported(@Param("cutoff") LocalDateTime cutoff);
@Query("""
select u from SysUser u
where u.status = 1
and lower(u.username) <> 'admin'
and coalesce(u.lastLoginAt, u.createdAt) < :cutoff
""")
List<SysUser> findInactiveExceptAdmin(@Param("cutoff") LocalDateTime cutoff);
}
......@@ -43,7 +43,7 @@ import java.util.stream.Collectors;
public class UserImportService {
private static final Logger log = LoggerFactory.getLogger(UserImportService.class);
private static final int MAX_ROWS = 200;
private static final int MAX_ROWS = 5000;
private static final long MAX_FILE_BYTES = 5L * 1024 * 1024;
private static final Map<String, String> HEADER_ALIASES = Map.ofEntries(
......@@ -121,6 +121,7 @@ public class UserImportService {
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
XSSFSheet sheet = workbook.createSheet("用户导入");
CellStyle headerStyle = headerStyle(workbook);
CellStyle textStyle = textStyle(workbook);
String[] headers = {"用户名", "密码", "姓名", "警号", "身份证号", "邮箱", "手机号", "角色", "状态"};
int[] widths = {18, 18, 16, 18, 22, 28, 18, 14, 14};
Row header = sheet.createRow(0);
......@@ -130,6 +131,10 @@ public class UserImportService {
cell.setCellStyle(headerStyle);
sheet.setColumnWidth(i, widths[i] * 256);
}
// 用户名、密码、警号、身份证号、手机号按文本处理,避免 Excel 转成科学计数法或丢失前导零
for (int column : new int[]{0, 1, 3, 4, 6}) {
sheet.setDefaultColumnStyle(column, textStyle);
}
sheet.createFreezePane(0, 1);
XSSFSheet help = workbook.createSheet("填写说明");
......@@ -138,12 +143,12 @@ public class UserImportService {
"2. 用户名:3-32 位字母、数字、下划线、点或中划线。",
"3. 密码:8-64 位;导入后请通知用户尽快修改初始密码。",
"4. 姓名:必填,最多 64 个字符。",
"5. 警号:普通用户必填,管理员可留空;填写时须为 1-32 位字母、数字或中划线,且不能重复。",
"6. 身份证号:可选,18 位,末位可为 X,且不能重复;请设置为文本格式。",
"7. 邮箱、手机号:可选;手机号须为 11 位中国大陆手机号,且不能重复。",
"5. 警号:普通用户必填,管理员可留空;填写时须为 1-32 位字母、数字或中划线,且不能重复;本列已预设为文本格式。",
"6. 身份证号:必填,18 位,末位可为 X,且不能重复;本列已预设为文本格式,请勿改成数值格式。",
"7. 邮箱:可选;手机号:必填,须为 11 位中国大陆手机号,且不能重复;手机号列已预设为文本格式。",
"8. 角色:ADMIN/USER 或 管理员/普通用户,留空默认 USER。",
"9. 状态:1/0 或 启用/停用,留空默认启用。",
"10. 单次最多导入 200 个用户;文件不超过 5MB。",
"10. 单次最多导入 5000 个用户;文件不超过 5MB。",
"11. 用户名、警号、身份证号或手机号重复/已存在时,该行会失败并返回原因,其他行继续导入。"
};
for (int i = 0; i < lines.length; i++) {
......@@ -194,6 +199,12 @@ public class UserImportService {
if (!columns.containsKey("policeNumber")) {
missing.add("警号");
}
if (!columns.containsKey("idCardNumber")) {
missing.add("身份证号");
}
if (!columns.containsKey("phone")) {
missing.add("手机号");
}
if (!missing.isEmpty()) {
throw new BizException("模板缺少必填列:" + String.join("、", missing));
}
......@@ -227,6 +238,9 @@ public class UserImportService {
try {
String username = readCell(row, columns.get("username"), formatter);
usernameForError = username;
requireTextIdentifier(row, columns.get("policeNumber"), "警号");
requireTextIdentifier(row, columns.get("idCardNumber"), "身份证号");
requireTextIdentifier(row, columns.get("phone"), "手机号");
String password = readCell(row, columns.get("password"), formatter);
String displayName = readCell(row, columns.get("displayName"), formatter);
String policeNumber = readCell(row, columns.get("policeNumber"), formatter);
......@@ -249,6 +263,12 @@ public class UserImportService {
if (!StringUtils.hasText(policeNumber) && !"ADMIN".equals(role)) {
throw new IllegalArgumentException("警号不能为空");
}
if (!StringUtils.hasText(idCardNumber)) {
throw new IllegalArgumentException("身份证号不能为空");
}
if (!StringUtils.hasText(phone)) {
throw new IllegalArgumentException("手机号不能为空");
}
String normalizedUsername = username.trim();
String usernameKey = normalizedUsername.toLowerCase(Locale.ROOT);
......@@ -288,7 +308,7 @@ public class UserImportService {
}
seenUsernames.add(usernameKey);
userService.create(request, status);
userService.createImported(request, status);
if (policeKey != null) {
seenPoliceNumbers.add(policeKey);
}
......@@ -316,8 +336,17 @@ public class UserImportService {
List.copyOf(errors), List.copyOf(importedUsernames));
}
private static String readCell(Row row, Integer column, DataFormatter formatter) {
private static void requireTextIdentifier(Row row, Integer column, String label) {
if (row == null || column == null) {
return;
}
Cell cell = row.getCell(column, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
if (cell != null && cell.getCellType() == CellType.NUMERIC) {
throw new IllegalArgumentException(label + "列必须为文本格式,请下载最新模板并重新填写");
}
}
private static String readCell(Row row, Integer column, DataFormatter formatter) { if (row == null || column == null) {
return null;
}
Cell cell = row.getCell(column, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
......@@ -382,6 +411,12 @@ public class UserImportService {
&& bytes[2] == 3 && bytes[3] == 4;
}
private static CellStyle textStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setDataFormat(workbook.createDataFormat().getFormat("@"));
return style;
}
private static CellStyle headerStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
......
......@@ -3,6 +3,8 @@ package com.zhihui.portal.user;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.common.BizException;
import com.zhihui.portal.common.PageResponse;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import com.zhihui.portal.security.SessionRevocationService;
import com.zhihui.portal.user.dto.UserDtos.CreateUserRequest;
import com.zhihui.portal.user.dto.UserDtos.ResetPasswordRequest;
import com.zhihui.portal.user.dto.UserDtos.UpdateUserRequest;
......@@ -20,6 +22,7 @@ import org.springframework.util.StringUtils;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
......@@ -36,12 +39,18 @@ public class UserService {
private final SysUserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JdbcTemplate jdbcTemplate;
private final SessionRevocationService sessionRevocationService;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public UserService(SysUserRepository userRepository, PasswordEncoder passwordEncoder,
JdbcTemplate jdbcTemplate) {
JdbcTemplate jdbcTemplate,
SessionRevocationService sessionRevocationService,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jdbcTemplate = jdbcTemplate;
this.sessionRevocationService = sessionRevocationService;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
}
public PageResponse<UserResponse> list(String keyword, int page, int size) {
......@@ -64,6 +73,16 @@ public class UserService {
@Transactional
public UserResponse create(CreateUserRequest request, Integer status) {
return createInternal(request, status, false, false);
}
@Transactional
public UserResponse createImported(CreateUserRequest request, Integer status) {
return createInternal(request, status, true, true);
}
private UserResponse createInternal(CreateUserRequest request, Integer status,
boolean mustChangePassword, boolean imported) {
String username = request.username().trim();
if (userRepository.existsByUsername(username)) {
throw new BizException("用户名已存在");
......@@ -95,14 +114,17 @@ public class UserService {
user.setPhone(phone);
user.setRole(role);
user.setStatus(initialStatus);
user.setMustChangePassword(mustChangePassword);
user.setImported(imported);
return UserResponse.from(userRepository.save(user));
}
@Transactional
public UserResponse update(Long id, UpdateUserRequest request, SecurityUser currentUser) {
SysUser user = find(id);
boolean editingSelf = currentUser.getId().equals(id);
if (editingSelf && !currentUser.getRole().equals(request.role())) {
SysUser actor = find(currentUser.getId());
boolean editingSelf = actor.getId().equals(id);
if (editingSelf && !actor.getRole().equals(request.role())) {
throw new BizException("不能修改自己的角色");
}
if (editingSelf && request.status() != null && request.status() != 1) {
......@@ -111,6 +133,13 @@ public class UserService {
if (request.status() != null && request.status() != 0 && request.status() != 1) {
throw new BizException("状态值不合法");
}
String oldUsername = user.getUsername();
String oldRole = user.getRole();
Integer oldStatus = user.getStatus();
String oldPoliceNumber = user.getPoliceNumber();
String oldIdCardNumber = user.getIdCardNumber();
String oldPhone = user.getPhone();
String username = request.username() == null ? "" : request.username().trim();
if (!USERNAME_PATTERN.matcher(username).matches()) {
throw new BizException("用户名需为 3-32 位字母、数字、下划线、点或中划线");
......@@ -155,7 +184,21 @@ public class UserService {
if (request.status() != null) {
user.setStatus(request.status());
}
return UserResponse.from(userRepository.save(user));
UserResponse response = UserResponse.from(userRepository.save(user));
boolean securityChange = !Objects.equals(oldUsername, user.getUsername())
|| !Objects.equals(oldRole, user.getRole())
|| !Objects.equals(oldStatus, user.getStatus())
|| !Objects.equals(oldPoliceNumber, user.getPoliceNumber())
|| !Objects.equals(oldIdCardNumber, user.getIdCardNumber())
|| !Objects.equals(oldPhone, user.getPhone());
if (securityChange) {
revokeUserAccess(oldUsername);
if (!Objects.equals(oldUsername, user.getUsername())) {
revokeUserAccess(user.getUsername());
}
}
return response;
}
private static String normalizePoliceNumber(String value, String role) {
......@@ -200,6 +243,28 @@ public class UserService {
return user.getEmail();
}
@Transactional
public void changeOwnPassword(Long userId, String currentPassword, String newPassword) {
SysUser user = find(userId);
if (user.getStatus() == null || user.getStatus() != 1) {
throw new BizException("账号已停用");
}
if (!StringUtils.hasText(currentPassword)
|| !passwordEncoder.matches(currentPassword, user.getPassword())) {
throw new BizException("当前密码不正确");
}
if (!StringUtils.hasText(newPassword) || newPassword.length() < 8 || newPassword.length() > 64) {
throw new BizException("新密码长度需为 8-64 位");
}
if (passwordEncoder.matches(newPassword, user.getPassword())) {
throw new BizException("新密码不能与原密码相同");
}
user.setPassword(passwordEncoder.encode(newPassword));
user.setMustChangePassword(false);
userRepository.save(user);
revokeUserAccess(user.getUsername());
}
public void verifyAdminPassword(Long userId, String password) {
SysUser user = find(userId);
if (!"ADMIN".equals(user.getRole())
......@@ -215,6 +280,7 @@ public class UserService {
SysUser user = find(id);
user.setPassword(passwordEncoder.encode(request.password()));
userRepository.save(user);
revokeUserAccess(user.getUsername());
}
@Transactional
......@@ -223,6 +289,7 @@ public class UserService {
throw new BizException("不能删除自己");
}
SysUser user = find(id);
revokeUserAccess(user.getUsername());
jdbcTemplate.update("DELETE FROM user_app_selection WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM user_launcher_order WHERE user_id = ?", id);
userRepository.delete(user);
......@@ -246,6 +313,7 @@ public class UserService {
if (users.size() != distinct.size()) {
throw new BizException("部分用户不存在,请刷新后重试");
}
users.forEach(user -> revokeUserAccess(user.getUsername()));
List<Object[]> rows = distinct.stream().map(id -> new Object[]{id}).toList();
jdbcTemplate.batchUpdate("DELETE FROM user_app_selection WHERE user_id = ?", rows);
jdbcTemplate.batchUpdate("DELETE FROM user_launcher_order WHERE user_id = ?", rows);
......@@ -253,6 +321,11 @@ public class UserService {
return users.size();
}
private void revokeUserAccess(String username) {
sessionRevocationService.revokeByUsername(username);
oauthAuthorizationRevocationService.revokeByPrincipalName(username);
}
public List<SysUser> findUsers(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return List.of();
......
package com.zhihui.portal.web;
import com.zhihui.portal.cert.CertificateAuthSettings;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class PageController {
private final CertificateAuthSettings certificateAuthSettings;
public PageController(CertificateAuthSettings certificateAuthSettings) {
this.certificateAuthSettings = certificateAuthSettings;
}
@GetMapping("/")
public String index() {
return "redirect:/spa/";
}
@GetMapping("/login")
public String login(Authentication authentication) {
public String login(Authentication authentication, Model model) {
if (authentication != null
&& authentication.isAuthenticated()
&& !(authentication instanceof AnonymousAuthenticationToken)) {
return "redirect:/";
}
model.addAttribute("certAuthEnabled", certificateAuthSettings.isClientReady());
return "login";
}
......
package com.zhihui.portal.web;
import com.zhihui.portal.audit.AuditEvents;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.common.BizException;
import com.zhihui.portal.user.UserService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class PasswordChangeController {
private final UserService userService;
private final AuditLogService auditLogService;
public PasswordChangeController(UserService userService, AuditLogService auditLogService) {
this.userService = userService;
this.auditLogService = auditLogService;
}
@GetMapping("/change-password")
public String page(Authentication authentication, Model model) {
SecurityUser user = currentUser(authentication);
if (user == null) {
return "redirect:/login";
}
model.addAttribute("mustChange", user.isMustChangePassword());
return "change-password";
}
@PostMapping("/change-password")
public String change(@RequestParam String currentPassword,
@RequestParam String newPassword,
@RequestParam String confirmPassword,
Authentication authentication,
HttpServletRequest request,
Model model) {
SecurityUser user = currentUser(authentication);
if (user == null) {
return "redirect:/login";
}
model.addAttribute("mustChange", user.isMustChangePassword());
if (!newPassword.equals(confirmPassword)) {
model.addAttribute("error", "两次输入的新密码不一致");
return "change-password";
}
try {
userService.changeOwnPassword(user.getId(), currentPassword, newPassword);
auditLogService.recordCurrent(AuditEvents.USER_PASSWORD_CHANGE, "USER",
String.valueOf(user.getId()), "username=" + user.getUsername(), true);
} catch (BizException ex) {
model.addAttribute("error", ex.getMessage());
return "change-password";
}
SecurityContextHolder.clearContext();
HttpSession session = request.getSession(false);
if (session != null) {
try {
session.invalidate();
} catch (IllegalStateException ignored) {
// 会话已被访问撤销时忽略
}
}
return "redirect:/login?passwordChanged";
}
private static SecurityUser currentUser(Authentication authentication) {
if (authentication == null
|| authentication instanceof AnonymousAuthenticationToken
|| !(authentication.getPrincipal() instanceof SecurityUser user)) {
return null;
}
return user;
}
}
server:
port: ${SERVER_PORT:8080}
forward-headers-strategy: framework
# 默认不信任代理头;仅在 zhihui.security.trust-forwarded-headers=true 时注册 ForwardedHeaderFilter
forward-headers-strategy: none
servlet:
session:
timeout: 8h
......@@ -48,6 +49,7 @@ spring:
redis:
namespace: zhihui:session
flush-mode: on_save
repository-type: indexed
thymeleaf:
cache: false
jackson:
......@@ -73,6 +75,27 @@ zhihui:
max-failures-per-user-ip: ${ZHIHUI_LOGIN_MAX_FAILURES_PER_USER_IP:5}
max-failures-per-ip: ${ZHIHUI_LOGIN_MAX_FAILURES_PER_IP:20}
window: ${ZHIHUI_LOGIN_FAILURE_WINDOW:15m}
# 数字证书登录:默认关闭,网关地址和应用标识由现场配置,不写入代码
cert-auth:
enabled: ${ZHIHUI_CERT_AUTH_ENABLED:false}
gateway-url: ${ZHIHUI_CERT_GATEWAY_URL:}
app-id: ${ZHIHUI_CERT_APP_ID:}
init-param: ${ZHIHUI_CERT_INIT_PARAM:}
init-param-file: ${ZHIHUI_CERT_INIT_PARAM_FILE:}
pin-code: ${ZHIHUI_CERT_PIN_CODE:false}
access-control: ${ZHIHUI_CERT_ACCESS_CONTROL:false}
allow-insecure-http: ${ZHIHUI_CERT_ALLOW_INSECURE_HTTP:false}
attributes-mode: ${ZHIHUI_CERT_ATTRIBUTES_MODE:portion}
subject-dn-id-attribute: ${ZHIHUI_CERT_SUBJECT_DN_ID_ATTRIBUTE:CN}
connect-timeout: ${ZHIHUI_CERT_CONNECT_TIMEOUT:5s}
request-timeout: ${ZHIHUI_CERT_REQUEST_TIMEOUT:15s}
challenge-ttl: ${ZHIHUI_CERT_CHALLENGE_TTL:2m}
max-response-bytes: ${ZHIHUI_CERT_MAX_RESPONSE_BYTES:262144}
max-signed-data-bytes: ${ZHIHUI_CERT_MAX_SIGNED_DATA_BYTES:524288}
trust-store: ${ZHIHUI_CERT_TRUST_STORE:}
trust-store-password: ${ZHIHUI_CERT_TRUST_STORE_PASSWORD:}
trust-store-type: ${ZHIHUI_CERT_TRUST_STORE_TYPE:PKCS12}
client-download-url: ${ZHIHUI_CERT_CLIENT_DOWNLOAD_URL:}
demo:
enabled: ${ZHIHUI_DEMO_ENABLED:true}
app-url: ${ZHIHUI_DEMO_APP_URL:http://localhost:8081}
......
UPDATE portal_app
SET icon_url = '/img/app-icons/zhijing.png'
WHERE app_code = 'repeated-alarms';
ALTER TABLE audit_log
ADD COLUMN login_type VARCHAR(16) NULL AFTER event_type;
ALTER TABLE sys_user
ADD COLUMN last_login_at DATETIME(6) NULL AFTER status,
ADD COLUMN must_change_password TINYINT(1) NOT NULL DEFAULT 0 AFTER last_login_at,
ADD COLUMN imported TINYINT(1) NOT NULL DEFAULT 0 AFTER must_change_password;
......@@ -22,6 +22,10 @@
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html,
body {
margin: 0;
......@@ -52,9 +56,11 @@ body {
z-index: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 48px 56px 44px;
align-items: center;
justify-content: center;
padding: clamp(36px, 3.5vw, 64px) clamp(40px, 4.2vw, 80px) clamp(34px, 3vw, 56px);
color: #f2f7fc;
text-align: center;
overflow: hidden;
}
......@@ -70,44 +76,69 @@ body {
pointer-events: none;
}
.brand-top {
.brand-hero {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
z-index: 1;
max-width: 720px;
}
.brand-mark {
width: 60px;
height: 60px;
display: block;
width: clamp(150px, 16vw, 280px);
height: auto;
object-fit: contain;
filter: drop-shadow(0 22px 36px rgba(0, 0, 0, 0.42)) drop-shadow(0 0 26px rgba(120, 190, 240, 0.28));
}
.brand-name {
margin-top: clamp(20px, 3vh, 36px);
font-family: var(--display);
font-size: 28px;
letter-spacing: 0.12em;
line-height: 1.2;
font-size: clamp(38px, 4vw, 68px);
font-weight: 600;
letter-spacing: 0.14em;
line-height: 1.1;
color: #f2f7fc;
text-shadow: 0 0 28px rgba(120, 190, 240, 0.24);
}
.brand-sub {
margin-top: 5px;
font-size: 13px;
margin-top: clamp(10px, 1.2vh, 16px);
font-size: clamp(13px, 1vw, 18px);
letter-spacing: 0.3em;
color: rgba(220, 231, 243, 0.62);
}
.brand-slogan {
display: flex;
align-items: center;
gap: clamp(12px, 1.2vw, 20px);
margin-top: clamp(26px, 4vh, 44px);
font-size: clamp(15px, 1.3vw, 21px);
letter-spacing: 0.16em;
color: rgba(220, 231, 243, 0.64);
color: #9fc7e6;
}
.brand-slogan i {
width: clamp(24px, 3vw, 52px);
height: 1px;
background: linear-gradient(90deg, transparent, rgba(159, 199, 230, 0.7));
}
.brand-slogan i:last-child {
background: linear-gradient(90deg, rgba(159, 199, 230, 0.7), transparent);
}
.brand-nodes {
position: absolute;
top: 100px;
left: 50%;
inset: 0;
z-index: 0;
display: block;
width: min(760px, 92%, calc(100svh - 200px));
height: calc(50svh + 136px);
transform: translateX(calc(-50% + 50px));
opacity: 0.8;
width: 100%;
height: 100%;
opacity: 0.38;
pointer-events: none;
filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.35));
}
......@@ -120,14 +151,14 @@ body {
display: flex;
flex-direction: column;
justify-content: center;
padding: 48px clamp(32px, 6vw, 96px);
padding: clamp(40px, 4.5vw, 80px) clamp(32px, 6vw, 96px);
}
.form-wrap {
width: 100%;
max-width: 430px;
max-width: clamp(430px, 30vw, 560px);
margin: 0 auto;
padding: 40px 36px;
padding: clamp(32px, 2.6vw, 46px) clamp(28px, 2.2vw, 40px);
background: linear-gradient(165deg, rgba(16, 54, 92, 0.82) 0%, rgba(8, 32, 60, 0.9) 100%);
border: 1px solid rgba(169, 201, 232, 0.24);
border-top: 2px solid rgba(169, 201, 232, 0.62);
......@@ -138,9 +169,9 @@ body {
}
.form-wrap h2 {
margin: 0 0 28px;
margin: 0 0 clamp(22px, 1.8vw, 32px);
font-family: var(--display);
font-size: 34px;
font-size: clamp(32px, 2.4vw, 42px);
font-weight: 600;
letter-spacing: 0.04em;
color: #f2f7fc;
......@@ -161,23 +192,53 @@ body {
border-color: #efd3d3;
}
.alert-success {
color: #86efac;
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.35);
}
.change-hint {
margin-bottom: 18px;
}
.change-logout {
margin-top: 18px;
}
.change-logout button {
width: 100%;
padding: 10px 14px;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 4px;
background: transparent;
color: #cbd5e1;
font-size: 13px;
cursor: pointer;
}
.change-logout button:hover {
border-color: rgba(148, 163, 184, 0.6);
color: #f8fafc;
}
.login-form {
display: flex;
flex-direction: column;
}
.login-form label {
margin: 0 0 8px;
font-size: 13px;
margin: 0 0 clamp(7px, 0.7vw, 10px);
font-size: clamp(13px, 1vw, 15px);
color: rgba(220, 231, 243, 0.86);
}
.login-form input:not([type="hidden"]) {
height: 46px;
margin-bottom: 20px;
padding: 0 14px;
height: clamp(46px, 3.3vw, 58px);
margin-bottom: clamp(18px, 1.4vw, 26px);
padding: 0 clamp(12px, 1.1vw, 18px);
font: inherit;
font-size: 14px;
font-size: clamp(14px, 1.05vw, 17px);
color: var(--text);
background: #fff;
border: 1px solid var(--line);
......@@ -188,16 +249,16 @@ body {
.field-control {
position: relative;
margin-bottom: 20px;
margin-bottom: clamp(18px, 1.4vw, 26px);
}
.field-icon {
position: absolute;
top: 50%;
left: 14px;
left: clamp(12px, 1vw, 18px);
z-index: 1;
width: 18px;
height: 18px;
width: clamp(18px, 1.35vw, 22px);
height: clamp(18px, 1.35vw, 22px);
color: #5b7189;
transform: translateY(-50%);
pointer-events: none;
......@@ -206,7 +267,7 @@ body {
.login-form .field-control input:not([type="hidden"]) {
width: 100%;
margin-bottom: 0;
padding-left: 44px;
padding-left: clamp(40px, 3vw, 54px);
}
.field-control:focus-within .field-icon {
......@@ -260,11 +321,11 @@ body {
.login-form button {
align-self: center;
width: min(200px, 100%);
height: 48px;
margin-top: 10px;
width: min(clamp(200px, 14vw, 260px), 100%);
height: clamp(46px, 3.3vw, 56px);
margin-top: clamp(8px, 0.8vw, 14px);
font: inherit;
font-size: 15px;
font-size: clamp(15px, 1.1vw, 17px);
letter-spacing: 0.08em;
color: #fff;
background: var(--primary);
......@@ -287,14 +348,153 @@ body {
outline-offset: 3px;
}
.login-tabs {
display: flex;
align-items: center;
gap: clamp(22px, 2vw, 32px);
margin: 0 0 clamp(20px, 1.8vw, 28px);
padding: 0;
background: none;
border: 0;
border-bottom: 1px solid rgba(169, 201, 232, 0.16);
border-radius: 0;
}
.login-tab {
padding: 0 0 clamp(9px, 0.8vw, 12px);
font: inherit;
font-size: clamp(14px, 1.05vw, 17px);
letter-spacing: 0.06em;
color: rgba(220, 231, 243, 0.55);
background: none;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
cursor: pointer;
transition: color 0.16s ease, border-color 0.16s ease;
}
.login-tab:hover {
color: #e4eef8;
}
.login-tab.is-active {
color: #a9d8ff;
background: none;
border-bottom-color: #a9d8ff;
box-shadow: none;
}
.login-tab:focus-visible {
outline: 2px solid var(--accent-blue-soft);
outline-offset: 3px;
}
.cert-login {
margin-top: 0;
}
.cert-login-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
height: clamp(46px, 3.3vw, 56px);
font: inherit;
font-size: clamp(14px, 1.05vw, 16px);
letter-spacing: 0.06em;
color: #dce7f3;
background: rgba(143, 184, 220, 0.06);
border: 1px solid rgba(169, 201, 232, 0.34);
border-radius: 4px;
cursor: pointer;
transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}
.cert-login-btn svg {
width: 20px;
height: 20px;
flex: none;
color: var(--accent-blue);
}
.cert-login-btn:hover {
background: rgba(143, 184, 220, 0.13);
border-color: rgba(191, 224, 255, 0.62);
}
.cert-login-btn:active {
transform: translateY(1px);
}
.cert-login-btn:focus-visible {
outline: 2px solid var(--accent-blue-soft);
outline-offset: 3px;
}
.cert-login-btn:disabled {
cursor: wait;
opacity: 0.62;
}
.cert-login-hint {
margin: 10px 0 0;
text-align: center;
font-size: 12px;
line-height: 1.6;
color: rgba(220, 231, 243, 0.5);
}
.cert-feedback {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
}
.cert-feedback.is-info {
color: #dce7f3;
background: rgba(143, 184, 220, 0.1);
border-color: rgba(169, 201, 232, 0.24);
}
.cert-feedback.is-error {
color: #ffdadd;
background: rgba(179, 38, 46, 0.2);
border-color: rgba(239, 211, 211, 0.26);
}
.cert-feedback.is-success {
color: #d9f5e5;
background: rgba(35, 108, 73, 0.22);
border-color: rgba(185, 230, 205, 0.24);
}
.cert-download {
display: block;
margin-top: 8px;
text-align: center;
font-size: 12px;
color: var(--accent-blue);
text-decoration: none;
}
.cert-download:hover {
color: #c4e3ff;
text-decoration: underline;
}
.form-foot {
position: absolute;
right: 0;
bottom: 22px;
bottom: clamp(18px, 1.6vh, 28px);
left: 0;
z-index: 2;
text-align: center;
font-size: 17px;
font-size: clamp(16px, 1.18vw, 20px);
letter-spacing: 0.08em;
color: rgba(220, 231, 243, 0.76);
}
......@@ -305,7 +505,7 @@ body {
.form-foot span + span {
margin-top: 4px;
font-size: 14px;
font-size: clamp(13px, 0.98vw, 17px);
letter-spacing: 0.12em;
color: rgba(220, 231, 243, 0.56);
}
......@@ -320,6 +520,32 @@ body {
padding: 24px 24px 20px;
}
.brand-hero {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 10px 18px;
}
.brand-mark {
width: 64px;
}
.brand-name {
margin-top: 0;
font-size: 30px;
}
.brand-sub {
margin-top: 0;
font-size: 12px;
letter-spacing: 0.2em;
}
.brand-slogan {
display: none;
}
.brand-nodes {
display: none;
}
......
(function () {
'use strict';
const tabs = Array.from(document.querySelectorAll('[data-login-tab]'));
function activateTab(tab) {
const target = tab.dataset.loginTab;
for (const item of tabs) {
const active = item === tab;
item.classList.toggle('is-active', active);
item.setAttribute('aria-selected', String(active));
}
for (const panel of document.querySelectorAll('[data-login-panel]')) {
panel.hidden = panel.dataset.loginPanel !== target;
}
if (target === 'cert') {
const certButton = document.getElementById('cert-login-button');
if (certButton) {
certButton.focus();
}
}
}
for (const tab of tabs) {
tab.addEventListener('click', () => activateTab(tab));
tab.addEventListener('keydown', (event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
return;
}
event.preventDefault();
const index = tabs.indexOf(tab);
const next = tabs[(index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length];
next.focus();
next.click();
});
}
const container = document.getElementById('cert-login');
if (!container) {
return;
}
const button = document.getElementById('cert-login-button');
const status = document.getElementById('cert-login-feedback');
const download = document.getElementById('cert-download-link');
const form = document.getElementById('cert-auth-form');
const originalInput = document.getElementById('cert-original');
const signedInput = document.getElementById('cert-signed-data');
const csrfMeta = document.querySelector('meta[name="_csrf"]');
const csrfParameterMeta = document.querySelector('meta[name="_csrf_parameter"]');
const configUrl = container.dataset.configUrl;
const challengeUrl = container.dataset.challengeUrl;
let busy = false;
let config = null;
function setStatus(message, tone) {
status.textContent = message;
status.className = 'cert-feedback is-' + tone;
status.hidden = false;
}
function clearStatus() {
status.textContent = '';
status.className = 'cert-feedback';
status.hidden = true;
}
function setBusy(value) {
busy = value;
button.disabled = value;
button.setAttribute('aria-busy', String(value));
}
function showDownload(url) {
if (!url || !/^(https?:\/\/|\/)/.test(url)) {
download.hidden = true;
download.removeAttribute('href');
return;
}
download.href = url;
download.hidden = false;
}
async function requestJson(url, options) {
const response = await fetch(url, {
credentials: 'same-origin',
cache: 'no-store',
...options,
});
const text = await response.text();
let data = {};
if (text) {
try {
data = JSON.parse(text);
} catch (ignored) {
data = {};
}
}
if (!response.ok) {
const fallback = response.status === 403
? '页面已过期,请刷新后重试。'
: '请求失败,请稍后重试。';
throw new Error(data && data.message ? data.message : fallback);
}
return data;
}
function plugin() {
if (!window.JIT_GW_ExtInterface) {
throw new Error('未检测到证书控件,请安装控件后重试。');
}
try {
const version = window.JIT_GW_ExtInterface.GetVersion();
if (!version) {
throw new Error('empty version');
}
} catch (error) {
throw new Error('未检测到证书控件,请安装控件后重试。');
}
return window.JIT_GW_ExtInterface;
}
function pluginError(code) {
if (code === 3758096386 || code === 2148532334 || code === 3758096385) {
return '已取消证书选择。';
}
if (code === -536870815 || code === 3758096481) {
return '未找到有效证书,请插入 UKey 后重试。';
}
return '证书读取或签名失败,请重试。';
}
function release(iface) {
try {
iface.Finalize();
} catch (ignored) {
try {
iface.ClearFilter();
} catch (ignoredAgain) {
// 控件释放失败时不阻断页面提示
}
}
}
function sign(iface, original) {
try {
iface.ClearFilter();
const initResult = iface.Initialize('', config.initParam);
if (initResult !== undefined && initResult !== null && initResult !== ''
&& Number(initResult) === 0) {
throw new Error('证书控件初始化失败,请检查控件和 UKey 后重试。');
}
try {
iface.SetChooseSingleCert(0);
} catch (ignored) {
// 旧版本控件可能没有该接口
}
if (config.pinCode) {
try {
iface.SetForcePinDialog(1);
} catch (ignored) {
// 旧版本控件可能没有该接口
}
}
const signed = iface.P7SignString(original, true, true);
const code = Number(iface.GetLastError());
if (code !== 0 || !signed) {
throw new Error(pluginError(code));
}
return signed;
} finally {
release(iface);
}
}
async function start() {
if (busy) {
return;
}
clearStatus();
showDownload('');
setBusy(true);
try {
config = await requestJson(configUrl);
if (!config.enabled || !config.initParam) {
throw new Error('数字证书登录暂不可用,请使用账号密码登录。');
}
showDownload(config.clientDownloadUrl);
setStatus('正在读取证书控件…', 'info');
const iface = plugin();
const csrfToken = csrfMeta ? csrfMeta.content : '';
const csrfParameter = csrfParameterMeta ? csrfParameterMeta.content : '';
if (!csrfToken || !csrfParameter) {
throw new Error('页面已过期,请刷新后重试。');
}
const challenge = await requestJson(challengeUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
[csrfParameter]: csrfToken,
}).toString(),
});
if (!challenge.original) {
throw new Error('认证原文获取失败,请重试。');
}
setStatus('请在证书控件中选择证书…', 'info');
const signed = sign(iface, challenge.original);
originalInput.value = challenge.original;
signedInput.value = signed;
setStatus('证书认证中…', 'info');
form.submit();
} catch (error) {
setStatus(error && error.message ? error.message : '数字证书登录失败,请稍后重试。',
'error');
setBusy(false);
button.focus();
}
}
button.addEventListener('click', start);
})();
......@@ -7,6 +7,7 @@
const ctx = canvas.getContext('2d')
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
const timeOrigin = performance.now()
const W = 1040
const H = 760
......@@ -18,32 +19,34 @@
{ weight: 0.5, x1: 20, x2: 1020, y1: 14, y2: 180 },
{ weight: 0.17, x1: 10, x2: 180, y1: 140, y2: 480 },
{ weight: 0.17, x1: 860, x2: 1030, y1: 140, y2: 480 },
{ weight: 0.08, x1: 60, x2: 260, y1: 420, y2: 560 },
{ weight: 0.08, x1: 780, x2: 980, y1: 420, y2: 560 }
{ weight: 0.08, x1: 60, x2: 260, y1: 380, y2: 500 },
{ weight: 0.08, x1: 780, x2: 980, y1: 380, y2: 500 }
]
function randomBetween(min, max) {
return min + Math.random() * (max - min)
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value))
}
function randomStart() {
for (let attempt = 0; attempt < 12; attempt++) {
let roll = Math.random()
const zone = ZONES.find((item) => (roll -= item.weight) <= 0) || ZONES[0]
return {
const start = {
x: randomBetween(zone.x1, zone.x2),
y: randomBetween(zone.y1, zone.y2)
}
if (Math.hypot(start.x - GATE.x, start.y - GATE.y) >= 260) {
return start
}
}
return { x: randomBetween(20, 1020), y: randomBetween(20, 160) }
}
function randomGeometry() {
const start = randomStart()
const end = {
x: GATE.x + clamp((start.x - GATE.x) * 0.35 + randomBetween(-60, 60), -240, 240),
y: GATE.y - 30 + randomBetween(-24, 24)
x: GATE.x + randomBetween(-100, 100),
y: GATE.y
}
const dx = start.x - GATE.x
const dy = start.y - GATE.y
......@@ -60,11 +63,11 @@
}
function makeParticle() {
const duration = randomBetween(7, 14)
const duration = randomBetween(8.75, 17.5)
return {
...randomGeometry(),
duration,
startedAt: -Math.random() * duration,
startedAt: -Math.random() * duration * 0.65,
active: true,
nextAt: 0
}
......@@ -159,7 +162,7 @@
function recycleParticle(particle, t) {
Object.assign(particle, randomGeometry())
particle.duration = randomBetween(7, 14)
particle.duration = randomBetween(8.75, 17.5)
particle.startedAt = t
particle.active = true
}
......@@ -168,7 +171,7 @@
const point = screenPoint(quadPoint(particle, progress))
const depth = 0.22 + progress * 0.58
const fadeIn = Math.min(1, progress / 0.05)
const fadeOut = Math.min(1, (1 - progress) / 0.06)
const fadeOut = Math.min(1, (1 - progress) / 0.3)
return {
x: point.x,
y: point.y,
......@@ -193,9 +196,7 @@
drawGate()
const t = time / 1000
const minDistance = 22 * (width / 700)
const dots = []
const t = Math.max(0, time - timeOrigin) / 1000
for (const particle of particles) {
if (!particle.active) {
......@@ -223,11 +224,6 @@
continue
}
if (dots.some((item) => Math.hypot(item.x - dot.x, item.y - dot.y) < minDistance)) {
continue
}
dots.push(dot)
drawDot(dot)
}
}
......
var JIT_GW_ExtInterface = function(){
var currentSessionWSURL = "";
var useActioveX = false;
var useNPPlugin = false;
/**
* simple polyfill Object.assign for IE <= 11
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
* @param {Object} target - target options
* @param {Object} options - new options
*/
var extendOptions = function (target, options) {
if (typeof Object.assign === 'function') {
Object.assign(target, options)
} else {
// for IE < 11
for (var key in options) {
target[key] = options[key]
}
}
}
var options = {};
var checkSessionWSURL = function ()
{
if( currentSessionWSURL=="" )
{
if ("https:" == document.location.protocol)
{
currentSessionWSURL = SendAndWaitMessage("https://127.0.0.1:10087/", "QueryService");
}
else
{
currentSessionWSURL = SendAndWaitMessage("http://127.0.0.1:10086/", "QueryService");
}
}
}
// 是否是IE
var isUseActioveX = function ()
{
if (!!window.ActiveXObject || "ActiveXObject" in window)
{
//try
//{
// var ax = new ActiveXObject("PNXClient.PNXDataTrans");
// useActioveX = true;
//}
//catch(e)
//{
// useActioveX = false;
//}
useActioveX = true;
}
else
{
useActioveX = false;
}
}
// 是否使用NPPlugin
var isUseNPPlugin = function()
{
checkSessionWSURL();
if ("{\"value\":\"\"}" == currentSessionWSURL)
{
useNPPlugin = true;
}
else
{
useNPPlugin = false;
}
}
var addActioveX = function(){
document.write("<object classid='clsid:9DD991F7-6FB0-4004-95A4-0A55006A8C42' width='0' height='0' id='PNXGWClient'></object>");
}
var addNPPlugin = function(){
document.write("<embed type='application/x-jit-auth-plugin' id='PNXGWClient' width='0' height='0'></embed>");
}
var SendAndWaitMessageEx = function (operatorCmd, sendMsg)
{
checkSessionWSURL();
var strSendMsg = operatorCmd + ":" + sendMsg;
return SendAndWaitMessage(currentSessionWSURL, strSendMsg);
}
var SendAndWaitMessage = function (wsurl, sendMsg)
{
var ResultMsg = "{\"value\":\"\"}";
if( ResultMsg == wsurl )
{
return ;
}
try{
var globalXmlHttpRequest = new XMLHttpRequest();
globalXmlHttpRequest.open("POST", wsurl, false);
globalXmlHttpRequest.setRequestHeader("Content-Type", "text/plain;charset=UTF-8");
globalXmlHttpRequest.send(sendMsg);
ResultMsg = globalXmlHttpRequest.responseText;
}catch(e){
currentSessionWSURL = "";
if (options.onError) {
options.onError.call(undefined, e);
}
}
return ResultMsg;
}
return {
Config: function(extendOption) {
if (extendOption && typeof extendOption === 'object') {
extendOptions(options, extendOption)
}
},
Init: function()
{
isUseActioveX();
if (useActioveX) {
addActioveX();
}
else
{
isUseNPPlugin();
if (useNPPlugin){
addNPPlugin();
}
}
},
// 功能:返回客户端的版本 HRESULT GetClientVersion([out,retval] BSTR* bstrClientVersion);
GetClientVersion: function()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetClientVersion();
}
else
{
var result = JSON.parse(SendAndWaitMessageEx("GetClientVersion", ""));
return result.value;
}
},
// 功能:下载客户端程序并安装 HRESULT SetupClient([in] BSTR strURL, [in] BSTR strHashValue, [in] BOOL bSync, [in] BOOL bOnlySSO, [out,retval] LONG* lRetVal);
SetupClient: function (bstrURL, bstrHashValue, bSync, bOnlySSO)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.SetupClient(bstrURL, bstrHashValue, bSync, bOnlySSO);
}
else
{
var jsonstr = {"strURL":bstrURL,"strHashValue":bstrHashValue,"bSync":bSync,"bOnlySSO":bOnlySSO};
var result = JSON.parse(SendAndWaitMessageEx("SetupClient", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:获取硬件指纹信息 HRESULT GetFinger([in] ULONG dwSign, [in] BSTR strGateWayIP, [out,retval] BSTR* bstrFinger);
GetFinger: function (dwSign, strGateWayIP)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetFinger(dwSign, strGateWayIP);
}
else
{
var jsonstr = {"dwSign":dwSign, "strGateWayIP":strGateWayIP};
var result = JSON.parse(SendAndWaitMessageEx("GetFinger", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:设置转发策略和代填策略 HRESULT SetPolicy([in] BSTR strProxyPolicy, [in] BSTR strSSOPolicy, [in] BSTR strGatewayAddress, [out,retval] LONG* lRetVal);
SetPolicy:function (strProxyPolicy, strSSOPolicy, strGatewayAddress)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.SetPolicy(strProxyPolicy, strSSOPolicy, strGatewayAddress);
}
else
{
var jsonstr = {"strProxyPolicy":strProxyPolicy, "strSSOPolicy":strSSOPolicy, "strGatewayAddress":strGatewayAddress};
var result = JSON.parse(SendAndWaitMessageEx("SetPolicy", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:启动认证信息模块 HRESULT GetAuthToken([in] BSTR strFileName,[in] BSTR strXmlData,[out,retval] BSTR* bstrInfo);
GetAuthToken: function (strFileName, strXmlData)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetAuthToken(strFileName, strXmlData);
}
else
{
var jsonstr = {"strFileName":strFileName, "strXmlData":strXmlData};
var result = JSON.parse(SendAndWaitMessageEx("GetAuthToken", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:打开BS应用 HRESULT NavigateURL([in] BSTR strUrl, [out,retval] LONG* lRetVal);
NavigateURL: function (strUrl)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.NavigateURL(strUrl);
}
else
{
var jsonstr = {"strUrl":strUrl};
var result = JSON.parse(SendAndWaitMessageEx("NavigateURL", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:打开CS应用 HRESULT ExecuteCS([in] BSTR strGatewayAddress, [in] BSTR strAppFlag, [in] BSTR strAppPath, [out,retval] LONG* lRetVal);
ExecuteCS: function (strGatewayAddress, strAppFlag, strAppPath)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.ExecuteCS(strGatewayAddress, strAppFlag, strAppPath);
}
else
{
var jsonstr = {"strGatewayAddress":strGatewayAddress, "strAppFlag":strAppFlag, "strAppPath":strAppPath};
var result = JSON.parse(SendAndWaitMessageEx("ExecuteCS", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:设置自动更新策略 HRESULT SetUpdatePolicy([in] BSTR strGatewayAddress, [in] SHORT nGatewayPort, [in] BSTR strUserToken, [in] ULONG ulUpdateDelay, [out, retval] LONG* lRetVal);
SetUpdatePolicy: function (strGatewayAddress, nGatewayPort, strUserToken, updatedelay)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.SetUpdatePolicy(strGatewayAddress, nGatewayPort, strUserToken, updatedelay);
}
else
{
var jsonstr = {"strGatewayAddress":strGatewayAddress, "nGatewayPort":nGatewayPort, "strUserToken":strUserToken, "ulUpdateDelay":updatedelay};
var result = JSON.parse(SendAndWaitMessageEx("SetUpdatePolicy", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:获取客户端IP HRESULT GetClientIP([in] BSTR strGatewayIP, [out, retval] BSTR* RetVal);
GetClientIP: function (strGatewayIP)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetClientIP(strGatewayIP);
}
else
{
var jsonstr = {"strGatewayIP":strGatewayIP};
var result = JSON.parse(SendAndWaitMessageEx("GetClientIP", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:登出网关 HRESULT GWLogout([in] BSTR strServerIP, [out, retval] LONG* lRetVal);
GWLogout: function (strServerIP)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GWLogout(strServerIP);
}
else
{
var jsonstr = {"strServerIP":strServerIP};
var result = JSON.parse(SendAndWaitMessageEx("GWLogout", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:运行安装包: 0 非静默安装,1 静默安装,2 同步安装,4 异步安装 HRESULT RunSetup([in] LONG lRunType, [in] BOOL bIsOnlySSO, [out, retval] LONG* lRetVal);
RunSetup: function (lRunType, bIsOnlySSO)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.RunSetup(lRunType, bIsOnlySSO);
}
else
{
var jsonstr = {"lRunType":lRunType, "bIsOnlySSO":bIsOnlySSO};
var result = JSON.parse(SendAndWaitMessageEx("RunSetup", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:获取下载进度 HRESULT GetDownloadProgress([out, retval] LONG* lRetVal);
GetDownloadProgress: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetDownloadProgress();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("GetDownloadProgress", jsonstr));
return result.value;
}
},
// 功能:是否安装完成 HRESULT IsInstallComplete([out, retval] BOOL* lRetVal);
IsInstallComplete: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.IsInstallComplete();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("IsInstallComplete", jsonstr));
return result.value;
}
},
// 功能:初始化签包对象 HRESULT Initialize([in] BSTR strAlgType, [in] BSTR strAuxParam, [out,retval] LONG* Result);
Initialize: function (strAlgType, strAuxParam)
{
if(useActioveX|| useNPPlugin )
{
return PNXGWClient.Initialize(strAlgType, strAuxParam);
}
else
{
var jsonstr = { "strAlgType":strAlgType, "strAuxParam":strAuxParam };
var result = JSON.parse(SendAndWaitMessageEx("Initialize", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:设置摘要算法 HRESULT SetDigestAlg([in] BSTR strDigestAlg, [out,retval] LONG* Result);
SetDigestAlg: function (strDigestAlg)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.SetDigestAlg(strDigestAlg);
}
else
{
var jsonstr = {"strDigestAlg":strDigestAlg};
var result = JSON.parse(SendAndWaitMessageEx("SetDigestAlg", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:设置单证书是否弹出对话框 HRESULT SetChooseSingleCert([in] ULONG isChoose, [out,retval] LONG* Result);
SetChooseSingleCert: function (isChoose)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.SetChooseSingleCert(isChoose);
}
else
{
var jsonstr = {"isChoose":isChoose};
var result = JSON.parse(SendAndWaitMessageEx("SetChooseSingleCert", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:添加证书过滤条件 HRESULT AddFilter([in] ULONG ulType, [in] BSTR strValue, [out,retval] LONG* Result);
AddFilter: function (ulType, strValue)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.AddFilter(ulType, strValue);
}
else
{
var jsonstr = {"ulType":ulType, "strValue":strValue};
var result = JSON.parse(SendAndWaitMessageEx("AddFilter", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:清除所有过滤条件 HRESULT ClearFilter([out,retval] LONG* Result);
ClearFilter: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.ClearFilter();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("ClearFilter", jsonstr));
return result.value;
}
},
// 功能:P1签名 HRESULT P1Sign([in] BSTR strValueBase64, [out,retval] BSTR* Result);
P1Sign: function (strValueBase64)
{
if(useActioveX|| useNPPlugin )
{
return PNXGWClient.P1Sign(strValueBase64);
}
else
{
var jsonstr = {"strValueBase64":strValueBase64};
var result = JSON.parse(SendAndWaitMessageEx("P1Sign", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:P1签名字符串 HRESULT P1SignString([in] BSTR strValue, [out,retval] BSTR* Result);
P1SignString: function (strValue)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.P1SignString(strValue);
}
else
{
var jsonstr = {"strValue":strValue};
var result = JSON.parse(SendAndWaitMessageEx("P1SignString", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:P7签名 HRESULT P7Sign([in] BSTR strValueBase64,[in] VARIANT_BOOL isDetach,[in] VARIANT_BOOL isIncludeCert, [out,retval] BSTR* Result);
P7Sign: function (strValueBase64, isDetach, isIncludeCert)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.P7Sign(strValueBase64, isDetach, isIncludeCert);
}
else
{
var jsonstr = {"strValueBase64":strValueBase64, "isDetach":isDetach, "isIncludeCert":isIncludeCert};
var result = JSON.parse(SendAndWaitMessageEx("P7Sign", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:P7签名字符串 HRESULT P7SignString([in] BSTR strValue,[in] VARIANT_BOOL isDetach,[in] VARIANT_BOOL isIncludeCert, [out,retval] BSTR* Result);
P7SignString: function (strValue, isDetach, isIncludeCert)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.P7SignString(strValue, isDetach, isIncludeCert);
}
else
{
var jsonstr = {"strValue":strValue, "isDetach":isDetach, "isIncludeCert":isIncludeCert};
var result = JSON.parse(SendAndWaitMessageEx("P7SignString", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:释放签名对象 HRESULT Finalize([out,retval] LONG* Result);
Finalize: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.Finalize();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("Finalize", jsonstr));
return result.value;
}
},
// 功能:获取签名版本 HRESULT GetSignVersion([out,retval] BSTR* Result);
GetSignVersion: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetSignVersion();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("GetSignVersion", jsonstr));
return result.value;
}
},
// 功能:获取签名证书 HRESULT GetSignCert([out,retval] BSTR* RetCert);
GetSignCert: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetSignCert();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("GetSignCert", jsonstr));
return result.value;
}
},
// 功能:获取错误码 HRESULT GetLastError([out,retval] ULONG* RetLong);
GetLastError: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetLastError();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("GetLastError", jsonstr));
return result.value;
}
},
// 功能:获取错误信息 HRESULT GetLastErrorMessage([out,retval] BSTR* RetStr);
GetLastErrorMessage: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetLastErrorMessage();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("GetLastErrorMessage", jsonstr));
return result.value;
}
},
// 功能:销毁认证对象 HRESULT DestoryAuth([out,retval] LONG* lRetVal);
DestoryAuth: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.DestoryAuth();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("DestoryAuth", jsonstr));
return result.value;
}
},
// 功能:获取证书类型 HRESULT GetSignCertType([out,retval] BSTR* RetCertType);
GetSignCertType: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetSignCertType();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("GetSignCertType", jsonstr));
return result.value;
}
},
// 功能:获取签名摘要算法 HRESULT GetSignHash([out,retval] BSTR* RetSignHash);
GetSignHash: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetSignHash();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("GetSignHash", jsonstr));
return result.value;
}
},
// 功能:使能调用序列 HRESULT CallQueueEnable([in] BOOL bCallQueueEnable, [in] BOOL bHeadInfoEnable, [out,retval] LONG* lRetVal);
CallQueueEnable: function (bCallQueueEnable, bHeadInfoEnable)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.CallQueueEnable(bCallQueueEnable, bHeadInfoEnable);
}
else
{
var jsonstr = {"bCallQueueEnable":bCallQueueEnable, "bHeadInfoEnable":bHeadInfoEnable};
var result = JSON.parse(SendAndWaitMessageEx("CallQueueEnable", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:清空调用序列结果 HRESULT CallQueueClear([out,retval] LONG* lRetVal);
CallQueueClear: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.CallQueueClear();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("CallQueueClear", jsonstr));
return result.value;
}
},
// 功能:获取调用序列结果 HRESULT CallQueueGet([out,retval] BSTR* bstrRetVal);
CallQueueGet: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.CallQueueGet();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("CallQueueGet", jsonstr));
return result.value;
}
},
// 功能:客户端安全策略检查 HRESULT DoClientSecurityCheck([in] BSTR strGatewayAddress, [in] SHORT usGatewayPort, [in] BSTR strSecurityPolicys, [out,retval] BOOL* RetVal);
DoClientSecurityCheck: function (strGatewayAddress, usGatewayPort, strSecurityPolicys)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.DoClientSecurityCheck(strGatewayAddress, usGatewayPort, strSecurityPolicys);
}
else
{
var jsonstr = {"strGatewayAddress":strGatewayAddress, "usGatewayPort":usGatewayPort, "strSecurityPolicys":strSecurityPolicys};
var result = JSON.parse(SendAndWaitMessageEx("DoClientSecurityCheck", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:获取通讯协议版本 HRESULT GetProtocolVersion([out,retval] BSTR* strProtocolVersion);
GetProtocolVersion: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetProtocolVersion();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("GetProtocolVersion", jsonstr));
return result.value;
}
},
// 功能:修改Key的Pin码 HRESULT ChangePinCode([in] BSTR strCertSn, [in] LONG lPinCodeType, [in] BSTR strCurPinCode, [in] BSTR strNewPinCode, [out,retval] LONG *pRet);
ChangePinCode: function (strCertSn, lPinCodeType, strCurPinCode, strNewPinCode)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.ChangePinCode(strCertSn, lPinCodeType, strCurPinCode, strNewPinCode);
}
else
{
var jsonstr = {"strCertSn":strCertSn, "lPinCodeType":lPinCodeType, "strCurPinCode":strCurPinCode, "strNewPinCode":strNewPinCode};
var result = JSON.parse(SendAndWaitMessageEx("ChangePinCode", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:等待策略设置完毕 HRESULT WaitSetPolicyFinish([in] BSTR strGatewayAddress, [out,retval] LONG* lRetVal);
WaitSetPolicyFinish: function (strGatewayAddress)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.WaitSetPolicyFinish(strGatewayAddress);
}
else
{
var jsonstr = {"strGatewayAddress":strGatewayAddress};
var result = JSON.parse(SendAndWaitMessageEx("WaitSetPolicyFinish", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:获取会话Token HRESULT GetSessionToken([in] BSTR strGatewayAddress, [out,retval] BSTR* bstrToken);
GetSessionToken: function (strGatewayAddress)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetSessionToken(strGatewayAddress);
}
else
{
var jsonstr = {"strGatewayAddress":strGatewayAddress};
var result = JSON.parse(SendAndWaitMessageEx("GetSessionToken", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:获取控件版本 HRESULT GetVersion([out,retval] BSTR* bstrRetVal);
GetVersion: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetVersion();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("GetVersion", jsonstr));
return result.value;
}
},
// 功能:设置语言资源 HRESULT SetLanguage([in] BSTR strLanguage, [out,retval] LONG* Result);
SetLanguage: function (strLanguage)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.SetLanguage(strLanguage);
}
else
{
var jsonstr = {"strLanguage":strLanguage};
var result = JSON.parse(SendAndWaitMessageEx("SetLanguage", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:获取用户属性 HRESULT GetAttribute([in] BSTR strGatewayAddress, [in] BSTR strAppFlag, [in] BSTR strAttributeName, [out, retval] BSTR* attributevalue);
GetAttribute: function (strGatewayAddress, strAppFlag, strAttributeName)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetAttribute(strGatewayAddress, strAppFlag, strAttributeName);
}
else
{
var jsonstr = {"strGatewayAddress":strGatewayAddress, "strAppFlag":strAppFlag, "strAttributeName":strAttributeName};
var result = JSON.parse(SendAndWaitMessageEx("GetAttribute", JSON.stringify(jsonstr)));
return result.value;
}
},
//功能:获取签名加密PIN码 HRESULT GetPinCode([out,retval] BSTR* Result);
GetPinCode: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetPinCode();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("GetPinCode", JSON.stringify(jsonstr)));
return result.value;
}
},
//功能:设置强制弹出PIN码框 HRESULT SetForcePinDialog([in] ULONG isForcePinDialog, [out,retval] LONG* Result);
SetForcePinDialog: function (isForcePinDialog)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.SetForcePinDialog(isForcePinDialog);
}
else
{
var jsonstr = {"isForcePinDialog":isForcePinDialog};
var result = JSON.parse(SendAndWaitMessageEx("SetForcePinDialog", JSON.stringify(jsonstr)));
return result.value;
}
},
//功能:返回报文认证属性 HRESULT MessageAuth([in] BSTR bstrGatewayAddress, [in] BSTR bstrAttributeName, [in] BSTR bstrAppID,[out,retval] BSTR* bstrAttributeValue);
MessageAuth: function (bstrGatewayAddress, bstrAttributeName, bstrAppID)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.MessageAuth(bstrGatewayAddress, bstrAttributeName, bstrAppID);
}
else
{
var jsonstr = {"bstrGatewayAddress":bstrGatewayAddress, "bstrAttributeName":bstrAttributeName, "bstrAppID":bstrAppID};
var result = JSON.parse(SendAndWaitMessageEx("MessageAuth", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:获取底板照片 HRESULT GetPhoto([in] BSTR bstrSvrIP, [in] USHORT usSvrPort, [in] BSTR bstrID, [out,retval] BSTR* bstrBase64);
GetPhoto: function (bstrSvrIP, usSvrPort, bstrID)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetPhoto(bstrSvrIP, usSvrPort, bstrID);
}
else
{
var jsonstr = {"bstrSvrIP":bstrSvrIP, "usSvrPort":usSvrPort, "bstrID":bstrID};
var result = JSON.parse(SendAndWaitMessageEx("GetPhoto", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:获取证书列表 HRESULT GetCertList([out,retval] BSTR* pbstrResult);
// 证书间用换行符分割,证书各项间用空格符分割
GetCertList: function()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.GetCertList();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("GetCertList", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:设置pin码并获取签名结果 HRESULT P1SignWithPin([in] BSTR bstrSN, [in] BSTR bstrPIN, [in] BSTR bstrOrg, [out, retval] BSTR* bstrResult);
P1SignWithPin: function(bstrSN, bstrPIN, bstrOrg)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.P1SignWithPin(bstrSN, bstrPIN, bstrOrg);
}
else
{
var jsonstr = {"bstrSN":bstrSN, "bstrPIN":bstrPIN, "bstrOrg":bstrOrg};
var result = JSON.parse(SendAndWaitMessageEx("P1SignWithPin", JSON.stringify(jsonstr)));
return result.value;
}
},
// 功能:清除所有过滤条件 HRESULT ClearFilterEx([out,retval] LONG* Result);
ClearFilterEx: function ()
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.ClearFilterEx();
}
else
{
var jsonstr = "";
var result = JSON.parse(SendAndWaitMessageEx("ClearFilterEx", jsonstr));
return result.value;
}
},
//功能:设置会话token HRESULT SetSessionToken([in] BSTR bstrGwAddr, [in] BSTR bstrToken, [out, retval] LONG* Result)
SetSessionToken: function(strGatewayAddress, strToken)
{
if( useActioveX || useNPPlugin )
{
return PNXGWClient.SetSessionToken(strGatewayAddress, strToken);
}
else
{
var jsonstr = {"strGatewayAddress":strGatewayAddress, "strToken":strToken};
var result = JSON.parse(SendAndWaitMessageEx("SetSessionToken", JSON.stringify(jsonstr)));
return result.value;
}
}
}
}();
(function(){
JIT_GW_ExtInterface.Init();
})();
\ No newline at end of file
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>修改密码 · 智汇平台</title>
<link rel="stylesheet" th:href="@{/css/login.css}">
<link rel="icon" th:href="@{/img/badge.png}" type="image/png">
</head>
<body>
<main class="login-shell">
<div class="brand-grid" aria-hidden="true"></div>
<section class="brand-panel">
<div class="brand-top">
<img th:src="@{/img/badge.png}" alt="" class="brand-mark">
<div>
<div class="brand-name">智汇平台</div>
<div class="brand-sub">统一身份认证平台</div>
</div>
</div>
</section>
<section class="form-panel">
<div class="form-wrap">
<h2>修改密码</h2>
<div class="alert alert-error" th:if="${error}" th:text="${error}" role="alert"></div>
<p class="cert-login-hint change-hint" th:if="${mustChange}">
首次登录需要修改初始密码,新密码不能与初始密码相同。
</p>
<form class="login-form" th:action="@{/change-password}" method="post">
<label for="currentPassword">当前密码</label>
<div class="field-control">
<input id="currentPassword" name="currentPassword" type="password"
autocomplete="current-password" required autofocus>
</div>
<label for="newPassword">新密码</label>
<div class="field-control">
<input id="newPassword" name="newPassword" type="password"
minlength="8" maxlength="64" autocomplete="new-password" required>
</div>
<label for="confirmPassword">确认新密码</label>
<div class="field-control">
<input id="confirmPassword" name="confirmPassword" type="password"
minlength="8" maxlength="64" autocomplete="new-password" required>
</div>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button type="submit">确认修改</button>
</form>
<form class="change-logout" th:action="@{/logout}" method="post">
<button type="submit">退出登录</button>
</form>
</div>
</section>
<footer class="form-foot">
<span>防城港市公安局网安支队501工作室</span>
<span>技术支持 © 2026</span>
</footer>
</main>
</body>
</html>
......@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 · 智汇平台</title>
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">
<meta name="_csrf_parameter" th:content="${_csrf.parameterName}">
<link rel="stylesheet" th:href="@{/css/login.css}">
<link rel="icon" th:href="@{/img/badge.png}" type="image/png">
</head>
......@@ -11,26 +14,53 @@
<main class="login-shell">
<div class="brand-grid" aria-hidden="true"></div>
<section class="brand-panel">
<div class="brand-top">
<canvas class="brand-nodes" width="1040" height="640" aria-hidden="true"></canvas>
<div class="brand-hero">
<img th:src="@{/img/badge.png}" alt="" class="brand-mark">
<div>
<div class="brand-name">智汇平台</div>
<div class="brand-sub">统一身份认证平台</div>
<div class="brand-slogan">
<i aria-hidden="true"></i>
<span>统一身份 · 安全互联</span>
<i aria-hidden="true"></i>
</div>
</div>
<canvas class="brand-nodes" width="1040" height="640" aria-hidden="true"></canvas>
</section>
<section class="form-panel">
<div class="form-wrap">
<div class="form-wrap"
th:with="certTab=${certAuthEnabled and param.error != null and #strings.startsWith(param.error[0], 'cert_')}">
<h2>登录系统</h2>
<div class="alert alert-success" th:if="${param.passwordChanged}" role="status">
密码修改成功,请使用新密码登录。
</div>
<div class="alert alert-error" th:if="${param.error}" role="alert">
<span th:if="${param.error[0] == 'locked'}">登录尝试过于频繁,请稍后再试。</span>
<span th:unless="${param.error[0] == 'locked'}">账号或密码错误,或账号已被停用。</span>
<span th:switch="${param.error[0]}">
<span th:case="'locked'">登录尝试过于频繁,请稍后再试。</span>
<span th:case="'ip_changed'">登录IP已变更,原登录已失效,请重新登录。</span>
<span th:case="'cert_failed'">数字证书认证失败,请重试或使用账号密码登录。</span>
<span th:case="'cert_no_user'">该证书未关联门户账号,请联系管理员维护身份证号。</span>
<span th:case="'cert_disabled'">账号已停用,请联系管理员。</span>
<span th:case="'cert_unavailable'">数字证书登录暂不可用,请使用账号密码登录。</span>
<span th:case="*">账号或密码错误,或账号已被停用。</span>
</span>
</div>
<div class="login-tabs" role="tablist" aria-label="登录方式" th:if="${certAuthEnabled}">
<button type="button" class="login-tab" role="tab"
data-login-tab="account" aria-controls="account-panel"
th:classappend="${certTab} ? '' : 'is-active'"
th:attr="aria-selected=${!certTab}">账号登录</button>
<button type="button" class="login-tab" role="tab"
data-login-tab="cert" aria-controls="cert-panel"
th:classappend="${certTab} ? 'is-active' : ''"
th:attr="aria-selected=${certTab}">证书登录</button>
</div>
<div id="account-panel" class="login-tab-panel" role="tabpanel"
data-login-panel="account" th:hidden="${certTab}">
<form class="login-form" th:action="@{/login}" method="post">
<label for="username">账号</label>
<div class="field-control">
......@@ -60,6 +90,36 @@
<button type="submit">登录</button>
</form>
</div>
<div id="cert-panel" class="login-tab-panel" role="tabpanel"
data-login-panel="cert" th:if="${certAuthEnabled}" th:hidden="${!certTab}">
<div id="cert-login" class="cert-login"
th:attr="data-config-url=@{/login/cert/config},data-challenge-url=@{/login/cert/challenge}">
<button type="button" id="cert-login-button" class="cert-login-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<circle cx="12" cy="9" r="5"/>
<path d="M8.6 13.2 7 21l5-2.4 5 2.4-1.6-7.8"/>
<path d="m9.8 9 1.4 1.4 3-3"/>
</svg>
<span>数字证书登录</span>
</button>
<p class="cert-login-hint">请插入数字证书(UKey),并确保已安装证书控件</p>
<div id="cert-login-feedback" class="cert-feedback" role="status"
aria-live="polite" hidden></div>
<a id="cert-download-link" class="cert-download" href="#" target="_blank"
rel="noopener" hidden>下载证书控件</a>
</div>
</div>
<form id="cert-auth-form" th:action="@{/login/cert/authenticate}"
method="post" hidden>
<input type="hidden" id="cert-original" name="original">
<input type="hidden" id="cert-signed-data" name="signed_data">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
</div>
</section>
<footer class="form-foot">
<span>防城港市公安局网安支队501工作室</span>
......@@ -67,5 +127,7 @@
</footer>
</main>
<script th:src="@{/js/login-network-3d.js}"></script>
<script th:if="${certAuthEnabled}" th:src="@{/js/pnxclient.js}"></script>
<script th:if="${certAuthEnabled}" th:src="@{/js/cert-login.js}"></script>
</body>
</html>
package com.zhihui.portal.cert;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.user.SysUser;
import com.zhihui.portal.user.SysUserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpSession;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CertificateAuthServiceTest {
private static final String ID_CARD = "450881198902261990";
private static final String SUBJECT_DN = "CN=傅健聪 " + ID_CARD + ",OU=00,C=CN";
private static final String MASKED_ID = "450881********1990";
@Mock
private GatewayMessageClient gatewayClient;
@Mock
private SysUserRepository userRepository;
private CertificateAuthProperties properties;
private CertificateAuthService service;
private MockHttpSession session;
@BeforeEach
void setUp() {
properties = new CertificateAuthProperties();
properties.setEnabled(true);
properties.setGatewayUrl("https://gateway.example/MessageService");
properties.setAppId("zhihui");
properties.setChallengeTtl(Duration.ofMinutes(2));
service = new CertificateAuthService(
properties, gatewayClient, new CertificateSubjectParser("CN"), userRepository);
session = new MockHttpSession();
lenient().when(gatewayClient.isAvailable()).thenReturn(true);
}
@Test
void createsChallengeAndStoresItInSession() {
CertificateAuthService.ChallengeResponse response = service.createChallenge(session);
assertNotNull(response.original());
assertFalse(response.original().isBlank());
assertEquals(response.original(), session.getAttribute(CertificateAuthService.SESSION_CHALLENGE));
assertNotNull(session.getAttribute(CertificateAuthService.SESSION_EXPIRES_AT));
assertEquals(120, response.expiresInSeconds());
}
@Test
void authenticatesCertificateUserBySubjectDn() {
CertificateAuthService.ChallengeResponse challenge = service.createChallenge(session);
when(gatewayClient.authenticate("127.0.0.1", challenge.original(), "SIGNED"))
.thenReturn(GatewayMessageClient.GatewayAuthResult.success(SUBJECT_DN));
when(userRepository.findByIdCardNumber(ID_CARD))
.thenReturn(Optional.of(user("user1", ID_CARD, 1)));
SecurityUser user = service.authenticate(
session, challenge.original(), "SIGNED", "127.0.0.1");
assertEquals("user1", user.getUsername());
assertEquals(ID_CARD, user.getIdCardNumber());
assertNull(user.getPassword());
assertEquals("USER", user.getRole());
verify(gatewayClient).authenticate("127.0.0.1", challenge.original(), "SIGNED");
}
@Test
void rejectsMismatchedChallengeWithoutCallingGateway() {
service.createChallenge(session);
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> service.authenticate(session, "wrong", "SIGNED", "127.0.0.1"));
assertEquals(CertificateAuthException.Reason.CHALLENGE_INVALID, exception.getReason());
verify(gatewayClient, never()).authenticate(any(), any(), any());
}
@Test
void rejectsExpiredChallenge() {
CertificateAuthService.ChallengeResponse challenge = service.createChallenge(session);
session.setAttribute(CertificateAuthService.SESSION_EXPIRES_AT,
Instant.now().minusSeconds(1).toEpochMilli());
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> service.authenticate(session, challenge.original(), "SIGNED", "127.0.0.1"));
assertEquals(CertificateAuthException.Reason.CHALLENGE_INVALID, exception.getReason());
}
@Test
void challengeIsSingleUse() {
CertificateAuthService.ChallengeResponse challenge = service.createChallenge(session);
when(gatewayClient.authenticate("127.0.0.1", challenge.original(), "SIGNED"))
.thenReturn(GatewayMessageClient.GatewayAuthResult.success(SUBJECT_DN));
when(userRepository.findByIdCardNumber(ID_CARD))
.thenReturn(Optional.of(user("user1", ID_CARD, 1)));
service.authenticate(session, challenge.original(), "SIGNED", "127.0.0.1");
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> service.authenticate(session, challenge.original(), "SIGNED", "127.0.0.1"));
assertEquals(CertificateAuthException.Reason.CHALLENGE_INVALID, exception.getReason());
verify(gatewayClient, times(1)).authenticate(any(), any(), any());
}
@Test
void mapsGatewayFailure() {
CertificateAuthService.ChallengeResponse challenge = service.createChallenge(session);
when(gatewayClient.authenticate("127.0.0.1", challenge.original(), "SIGNED"))
.thenReturn(GatewayMessageClient.GatewayAuthResult.failure("CERT_REVOKED", "证书被吊销"));
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> service.authenticate(session, challenge.original(), "SIGNED", "127.0.0.1"));
assertEquals(CertificateAuthException.Reason.GATEWAY_REJECTED, exception.getReason());
assertEquals("证书用户", exception.getAuditIdentity());
assertEquals(true, exception.isCountFailure());
}
@Test
void rejectsCertificateWithoutIdCardInSubject() {
CertificateAuthService.ChallengeResponse challenge = service.createChallenge(session);
when(gatewayClient.authenticate("127.0.0.1", challenge.original(), "SIGNED"))
.thenReturn(GatewayMessageClient.GatewayAuthResult.success(
"CN=傅健聪,OU=00,C=CN"));
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> service.authenticate(session, challenge.original(), "SIGNED", "127.0.0.1"));
assertEquals(CertificateAuthException.Reason.CERT_INVALID, exception.getReason());
verify(userRepository, never()).findByIdCardNumber(any());
}
@Test
void rejectsCertificateWithoutPortalUser() {
CertificateAuthService.ChallengeResponse challenge = service.createChallenge(session);
when(gatewayClient.authenticate("127.0.0.1", challenge.original(), "SIGNED"))
.thenReturn(GatewayMessageClient.GatewayAuthResult.success(SUBJECT_DN));
when(userRepository.findByIdCardNumber(ID_CARD)).thenReturn(Optional.empty());
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> service.authenticate(session, challenge.original(), "SIGNED", "127.0.0.1"));
assertEquals(CertificateAuthException.Reason.NO_USER, exception.getReason());
assertEquals(MASKED_ID, exception.getAuditIdentity());
assertFalse(exception.isCountFailure());
}
@Test
void rejectsDisabledUser() {
CertificateAuthService.ChallengeResponse challenge = service.createChallenge(session);
when(gatewayClient.authenticate("127.0.0.1", challenge.original(), "SIGNED"))
.thenReturn(GatewayMessageClient.GatewayAuthResult.success(SUBJECT_DN));
when(userRepository.findByIdCardNumber(ID_CARD))
.thenReturn(Optional.of(user("user1", ID_CARD, 0)));
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> service.authenticate(session, challenge.original(), "SIGNED", "127.0.0.1"));
assertEquals(CertificateAuthException.Reason.USER_DISABLED, exception.getReason());
}
@Test
void rejectsBuiltInUsernameAccount() {
CertificateAuthService.ChallengeResponse challenge = service.createChallenge(session);
when(gatewayClient.authenticate("127.0.0.1", challenge.original(), "SIGNED"))
.thenReturn(GatewayMessageClient.GatewayAuthResult.success(SUBJECT_DN));
when(userRepository.findByIdCardNumber(ID_CARD))
.thenReturn(Optional.of(user("admin", ID_CARD, 1)));
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> service.authenticate(session, challenge.original(), "SIGNED", "127.0.0.1"));
assertEquals(CertificateAuthException.Reason.ACCOUNT_DENIED, exception.getReason());
}
@Test
void rejectsWhenGatewayIsUnavailable() {
lenient().when(gatewayClient.isAvailable()).thenReturn(false);
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> service.createChallenge(session));
assertEquals(CertificateAuthException.Reason.NOT_CONFIGURED, exception.getReason());
}
@Test
void rejectsWhenDisabled() {
CertificateAuthProperties disabled = new CertificateAuthProperties();
disabled.setEnabled(false);
CertificateAuthService disabledService = new CertificateAuthService(
disabled, gatewayClient, new CertificateSubjectParser("CN"), userRepository);
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> disabledService.createChallenge(session));
assertEquals(CertificateAuthException.Reason.NOT_CONFIGURED, exception.getReason());
}
private static SysUser user(String username, String idCardNumber, int status) {
SysUser user = new SysUser();
user.setId(7L);
user.setUsername(username);
user.setDisplayName("傅健聪");
user.setPoliceNumber("661885");
user.setIdCardNumber(idCardNumber);
user.setPhone("13800138000");
user.setEmail("user@example.com");
user.setRole("USER");
user.setStatus(status);
return user;
}
}
package com.zhihui.portal.cert;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.LoginAttemptService;
import com.zhihui.portal.security.LoginSuccessHandler;
import com.zhihui.portal.security.LoginType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CertificateAuthenticationFilterTest {
@Mock
private CertificateAuthService authService;
@Mock
private LoginSuccessHandler successHandler;
@Mock
private CertificateLoginFailureHandler failureHandler;
@Mock
private ClientInfoResolver clientInfoResolver;
@Mock
private LoginAttemptService loginAttemptService;
@Mock
private AuditLogService auditLogService;
private CertificateAuthenticationFilter filter;
private MockHttpServletRequest request;
private MockHttpServletResponse response;
private MockHttpSession session;
@BeforeEach
void setUp() {
filter = new CertificateAuthenticationFilter(
authService, successHandler, failureHandler, clientInfoResolver,
loginAttemptService, auditLogService);
request = new MockHttpServletRequest("POST", "/login/cert/authenticate");
response = new MockHttpServletResponse();
session = new MockHttpSession();
request.setSession(session);
request.setParameter("original", "challenge");
request.setParameter("signed_data", "QUJD");
}
@Test
void authenticatesAndReturnsSecurityUser() throws Exception {
SecurityUser user = new SecurityUser(7L, "user1", null, "傅健聪",
"661885", "450881198902261990", "13800138000", "user@example.com",
"USER", true);
when(clientInfoResolver.resolveIp(request)).thenReturn("127.0.0.1");
when(loginAttemptService.check(null, "127.0.0.1"))
.thenReturn(new LoginAttemptService.Decision(false, null, 0));
when(authService.authenticate(session, "challenge", "QUJD", "127.0.0.1"))
.thenReturn(user);
Authentication authentication = filter.attemptAuthentication(request, response);
assertSame(user, authentication.getPrincipal());
assertEquals(List.of(new SimpleGrantedAuthority("ROLE_USER")),
List.copyOf(authentication.getAuthorities()));
assertEquals("登录成功(数字证书)",
request.getAttribute(LoginSuccessHandler.LOGIN_DETAIL_ATTRIBUTE));
assertEquals(LoginType.CERTIFICATE.name(),
request.getAttribute(LoginSuccessHandler.LOGIN_TYPE_ATTRIBUTE));
}
@Test
void rejectsRateLimitedAttemptBeforeReadingChallenge() {
when(clientInfoResolver.resolveIp(request)).thenReturn("127.0.0.1");
when(clientInfoResolver.resolveUserAgent(request)).thenReturn("UA");
when(loginAttemptService.check(null, "127.0.0.1"))
.thenReturn(new LoginAttemptService.Decision(true, "IP", 42));
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> filter.attemptAuthentication(request, response));
assertEquals(CertificateAuthException.Reason.RATE_LIMITED, exception.getReason());
verify(auditLogService).recordLoginBlocked(isNull(), eq("127.0.0.1"), eq("UA"),
eq("IP"), eq(42L), eq(LoginType.CERTIFICATE));
verify(authService, never()).authenticate(any(), any(), any(), any());
}
@Test
void rejectsMissingSession() {
when(clientInfoResolver.resolveIp(request)).thenReturn("127.0.0.1");
when(loginAttemptService.check(null, "127.0.0.1"))
.thenReturn(new LoginAttemptService.Decision(false, null, 0));
request.setSession(null);
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> filter.attemptAuthentication(request, response));
assertEquals(CertificateAuthException.Reason.CHALLENGE_INVALID, exception.getReason());
}
}
package com.zhihui.portal.cert;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpSession;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CertificateLoginControllerTest {
@Mock
private CertificateAuthService authService;
@Mock
private CertificateAuthSettings settings;
private CertificateLoginController controller;
@BeforeEach
void setUp() {
controller = new CertificateLoginController(authService, settings);
}
@Test
void exposesBrowserSettingsButNotGatewayAddress() {
when(settings.isClientReady()).thenReturn(true);
when(settings.getInitParam()).thenReturn("<authinfo><liblist /></authinfo>");
when(settings.getClientDownloadUrl()).thenReturn("/downloads/pnxclient");
CertificateLoginController.CertificateLoginConfig config =
controller.config().getBody();
assertTrue(config.enabled());
assertEquals("<authinfo><liblist /></authinfo>", config.initParam());
assertEquals("/downloads/pnxclient", config.clientDownloadUrl());
}
@Test
void hidesLoginWhenInitParamMissing() {
when(settings.isClientReady()).thenReturn(false);
CertificateLoginController.CertificateLoginConfig config =
controller.config().getBody();
assertFalse(config.enabled());
}
@Test
void returnsServiceUnavailableWhenDisabled() {
when(authService.isEnabled()).thenReturn(false);
ResponseEntity<?> response = controller.challenge(new MockHttpSession());
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode());
}
@Test
void returnsChallengeWhenEnabled() {
when(authService.isEnabled()).thenReturn(true);
when(authService.createChallenge(org.mockito.ArgumentMatchers.any()))
.thenReturn(new CertificateAuthService.ChallengeResponse("original", 120));
ResponseEntity<?> response = controller.challenge(new MockHttpSession());
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(new CertificateAuthService.ChallengeResponse("original", 120),
response.getBody());
}
}
package com.zhihui.portal.cert;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CertificateSubjectParserTest {
private final CertificateSubjectParser parser = new CertificateSubjectParser("CN");
@Test
void extractsIdCardNumberFromCn() {
Optional<String> result = parser.extractIdCardNumber(
"CN=傅健聪 450881198902261990,OU=00,OU=22,O=46,L=00,L=01,ST=45,C=CN");
assertEquals(Optional.of("450881198902261990"), result);
}
@Test
void normalizesLowercaseX() {
Optional<String> result = parser.extractIdCardNumber(
"CN=张三 11010119900101123x,OU=00,C=CN");
assertEquals(Optional.of("11010119900101123X"), result);
}
@Test
void supportsEscapedCommaInCn() {
Optional<String> result = parser.extractIdCardNumber(
"CN=张三\\, 110101199001011234,OU=00,C=CN");
assertEquals(Optional.of("110101199001011234"), result);
}
@Test
void rejectsWhenIdCardNumberIsOutsideConfiguredAttribute() {
Optional<String> result = parser.extractIdCardNumber(
"CN=张三,OU=110101199001011234,C=CN");
assertTrue(result.isEmpty());
}
@Test
void rejectsMultipleIds() {
Optional<String> result = parser.extractIdCardNumber(
"CN=张三 110101199001011234,CN=李四 110101199001015678,C=CN");
assertTrue(result.isEmpty());
}
@Test
void rejectsMissingId() {
Optional<String> result = parser.extractIdCardNumber("CN=张三,OU=00,C=CN");
assertTrue(result.isEmpty());
}
@Test
void supportsConfiguredUidAttribute() {
CertificateSubjectParser uidParser = new CertificateSubjectParser("UID");
Optional<String> result = uidParser.extractIdCardNumber(
"UID=110101199001011234,CN=张三,C=CN");
assertEquals(Optional.of("110101199001011234"), result);
}
@Test
void rejectsInvalidDn() {
assertTrue(parser.extractIdCardNumber("not a valid dn").isEmpty());
}
}
package com.zhihui.portal.cert;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class GatewayMessageClientTest {
private static final String SUCCESS_RESPONSE = """
<?xml version="1.0" encoding="UTF-8"?>
<message>
<head>
<version>1.0</version>
<serviceType>AuthenService</serviceType>
<messageState>false</messageState>
</head>
<body>
<authResultSet allFailed="true">
<authResult authMode="cert" success="true" />
</authResultSet>
<accessControlResult>Permit</accessControlResult>
<attributes>
<attr name="X509Certificate.SubjectDN"
namespace="http://www.jit.com.cn/cinas/ias/ns/saml/saml11/X.509">
CN=傅健聪 450881198902261990,OU=00,C=CN
</attr>
</attributes>
</body>
</message>
""";
private HttpServer server;
private GatewayMessageClient client;
private final AtomicReference<String> requestBody = new AtomicReference<>();
private volatile String responseXml;
private volatile int responseStatus;
@BeforeEach
void setUp() throws Exception {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/MessageService", exchange -> {
requestBody.set(new String(exchange.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8));
byte[] body = responseXml.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "text/xml;charset=UTF-8");
exchange.sendResponseHeaders(responseStatus, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
responseXml = SUCCESS_RESPONSE;
responseStatus = 200;
client = new GatewayMessageClient(properties(true, "http://127.0.0.1:"
+ server.getAddress().getPort() + "/MessageService"));
}
@AfterEach
void tearDown() {
if (server != null) {
server.stop(0);
}
}
@Test
void sendsCertificateAuthRequestAndParsesSubjectDn() {
GatewayMessageClient.GatewayAuthResult result =
client.authenticate("127.0.0.1", "challenge-123", "QUJD");
assertTrue(result.success());
assertEquals("CN=傅健聪 450881198902261990,OU=00,C=CN", result.subjectDn());
String body = requestBody.get();
assertTrue(body.contains("<appId>testApp</appId>"));
assertTrue(body.contains("authMode=\"cert\""));
assertTrue(body.contains("<detach>QUJD</detach>"));
assertTrue(body.contains("<original>"
+ Base64.getEncoder().encodeToString("challenge-123"
.getBytes(StandardCharsets.UTF_8)) + "</original>"));
assertTrue(body.contains("<clientIP>127.0.0.1</clientIP>"));
assertTrue(body.contains("<accessControl>false</accessControl>"));
assertTrue(body.contains("X509Certificate.SubjectDN"));
}
@Test
void ignoresAllFailedWhenCertResultSucceeded() {
assertTrue(SUCCESS_RESPONSE.contains("allFailed=\"true\""));
assertTrue(client.authenticate("127.0.0.1", "challenge", "QUJD").success());
}
@Test
void repairsUnescapedAmpersandInSubjectDn() {
responseXml = SUCCESS_RESPONSE.replace(
"CN=傅健聪 450881198902261990",
"CN=张三 A&B 450881198902261990");
GatewayMessageClient.GatewayAuthResult result =
client.authenticate("127.0.0.1", "challenge", "QUJD");
assertTrue(result.success());
assertEquals("CN=张三 A&B 450881198902261990,OU=00,C=CN", result.subjectDn());
}
@Test
void returnsGatewayBusinessError() {
responseXml = """
<?xml version="1.0" encoding="UTF-8"?>
<message>
<head>
<version>1.0</version>
<serviceType>AuthenService</serviceType>
<messageState>true</messageState>
<messageCode>MESSAGE_CODE_13001001</messageCode>
<messageDesc>报文解析异常</messageDesc>
</head>
<body />
</message>
""";
GatewayMessageClient.GatewayAuthResult result =
client.authenticate("127.0.0.1", "challenge", "QUJD");
assertFalse(result.success());
assertEquals("MESSAGE_CODE_13001001", result.errorCode());
}
@Test
void returnsCertificateFailure() {
responseXml = """
<?xml version="1.0" encoding="UTF-8"?>
<message>
<head><messageState>false</messageState></head>
<body>
<authResultSet allFailed="false">
<authResult authMode="cert" success="false">
<authMessageCode>00000002</authMessageCode>
<authMessageDesc>证书已过期</authMessageDesc>
</authResult>
</authResultSet>
</body>
</message>
""";
GatewayMessageClient.GatewayAuthResult result =
client.authenticate("127.0.0.1", "challenge", "QUJD");
assertFalse(result.success());
assertEquals("00000002", result.errorCode());
}
@Test
void rejectsAccessDeniedResult() {
responseXml = SUCCESS_RESPONSE.replace(
"<accessControlResult>Permit</accessControlResult>",
"<accessControlResult>Deny</accessControlResult>");
GatewayMessageClient.GatewayAuthResult result =
client.authenticate("127.0.0.1", "challenge", "QUJD");
assertFalse(result.success());
assertEquals("ACCESS_DENIED", result.errorCode());
}
@Test
void parsesHttp500BusinessError() {
responseStatus = 500;
responseXml = """
<?xml version="1.0" encoding="UTF-8"?>
<message>
<head>
<messageState>true</messageState>
<messageCode>GW_500</messageCode>
<messageDesc>网关内部错误</messageDesc>
</head>
<body />
</message>
""";
GatewayMessageClient.GatewayAuthResult result =
client.authenticate("127.0.0.1", "challenge", "QUJD");
assertFalse(result.success());
assertEquals("GW_500", result.errorCode());
}
@Test
void rejectsXxeResponse() {
responseXml = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE message [<!ENTITY xxe SYSTEM "file:///C:/Windows/win.ini">]>
<message>
<head><messageState>false</messageState></head>
<body>
<authResultSet><authResult authMode="cert" success="true" /></authResultSet>
<attributes>
<attr name="X509Certificate.SubjectDN"
namespace="http://www.jit.com.cn/cinas/ias/ns/saml/saml11/X.509">
&xxe;
</attr>
</attributes>
</body>
</message>
""";
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> client.authenticate("127.0.0.1", "challenge", "QUJD"));
assertEquals(CertificateAuthException.Reason.GATEWAY_REJECTED, exception.getReason());
}
@Test
void rejectsInvalidSignatureBeforeCallingGateway() {
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> client.authenticate("127.0.0.1", "challenge", "not base64!"));
assertEquals(CertificateAuthException.Reason.CERT_INVALID, exception.getReason());
assertEquals(null, requestBody.get());
}
@Test
void httpRequiresExplicitOptIn() {
GatewayMessageClient insecure = new GatewayMessageClient(properties(false,
"http://127.0.0.1:" + server.getAddress().getPort() + "/MessageService"));
assertFalse(insecure.isAvailable());
CertificateAuthException exception = assertThrows(CertificateAuthException.class,
() -> insecure.authenticate("127.0.0.1", "challenge", "QUJD"));
assertEquals(CertificateAuthException.Reason.GATEWAY_UNAVAILABLE, exception.getReason());
}
private static CertificateAuthProperties properties(boolean allowHttp, String url) {
CertificateAuthProperties properties = new CertificateAuthProperties();
properties.setEnabled(true);
properties.setGatewayUrl(url);
properties.setAppId("testApp");
properties.setAllowInsecureHttp(allowHttp);
properties.setConnectTimeout(Duration.ofSeconds(2));
properties.setRequestTimeout(Duration.ofSeconds(2));
return properties;
}
}
package com.zhihui.portal.security;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LoginIpBindingFilterTest {
@Mock
private ClientInfoResolver clientInfoResolver;
@Mock
private AuditLogService auditLogService;
@Mock
private SessionRevocationService sessionRevocationService;
@Mock
private OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
private LoginIpBindingFilter filter;
private SecurityUser user;
private MockHttpSession session;
@BeforeEach
void setUp() {
filter = new LoginIpBindingFilter(clientInfoResolver, auditLogService,
sessionRevocationService, oauthAuthorizationRevocationService);
user = new SecurityUser(7L, "user1", null, "测试用户", "660001",
"110101199001011234", "13800138000", "user@example.com", "USER", true);
session = new MockHttpSession();
session.setAttribute(LoginSession.IP_ATTRIBUTE, "10.0.0.1");
session.setAttribute(LoginSession.TYPE_ATTRIBUTE, LoginType.PASSWORD.name());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities()));
SecurityContextHolder.setContext(context);
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void passesRequestWhenIpMatches() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/me");
request.setSession(session);
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
when(clientInfoResolver.resolveIp(request)).thenReturn("10.0.0.1");
filter.doFilter(request, response, chain);
assertEquals(request, chain.getRequest());
verifyNoInteractions(auditLogService, sessionRevocationService,
oauthAuthorizationRevocationService);
}
@Test
void revokesAllAccessWhenIpChanges() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/me");
request.setSession(session);
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
when(clientInfoResolver.resolveIp(request)).thenReturn("10.0.0.2");
when(clientInfoResolver.resolveUserAgent(request)).thenReturn("UA");
filter.doFilter(request, response, chain);
assertEquals(401, response.getStatus());
assertTrue(response.getContentAsString().contains("\"code\":\"LOGIN_IP_CHANGED\""));
assertTrue(session.isInvalid());
assertNull(SecurityContextHolder.getContext().getAuthentication());
assertNull(chain.getRequest());
verify(auditLogService).recordLoginIpChanged(user, "10.0.0.1", "10.0.0.2",
"UA", LoginType.PASSWORD);
verify(sessionRevocationService).revokeByUsername("user1");
verify(oauthAuthorizationRevocationService).revokeByPrincipalName("user1");
}
@Test
void redirectsHtmlRequestWhenIpChanges() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/spa/");
request.addHeader("Accept", "text/html");
request.setSession(session);
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
when(clientInfoResolver.resolveIp(request)).thenReturn("10.0.0.2");
when(clientInfoResolver.resolveUserAgent(request)).thenReturn("UA");
filter.doFilter(request, response, chain);
assertEquals("/login?error=ip_changed", response.getRedirectedUrl());
assertTrue(session.isInvalid());
}
@Test
void forcesLoginForLegacySessionWithoutBoundIp() throws Exception {
session.removeAttribute(LoginSession.IP_ATTRIBUTE);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/me");
request.setSession(session);
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
when(clientInfoResolver.resolveIp(request)).thenReturn("10.0.0.2");
when(clientInfoResolver.resolveUserAgent(request)).thenReturn("UA");
filter.doFilter(request, response, chain);
assertEquals(401, response.getStatus());
assertTrue(session.isInvalid());
verify(auditLogService).recordLoginIpChanged(user, null, "10.0.0.2",
"UA", LoginType.PASSWORD);
verify(sessionRevocationService, never()).revokeByUsername("user1");
verify(oauthAuthorizationRevocationService, never()).revokeByPrincipalName("user1");
}
@Test
void passesUnauthenticatedRequest() throws Exception {
SecurityContextHolder.clearContext();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/me");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertEquals(request, chain.getRequest());
verifyNoInteractions(clientInfoResolver, auditLogService, sessionRevocationService,
oauthAuthorizationRevocationService);
}
}
package com.zhihui.portal.security;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.user.SysUserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LoginSuccessHandlerTest {
@Mock
private LoginAttemptService loginAttemptService;
@Mock
private ClientInfoResolver clientInfoResolver;
@Mock
private AuditLogService auditLogService;
@Mock
private SysUserRepository userRepository;
private LoginSuccessHandler handler;
private SecurityUser user;
@BeforeEach
void setUp() {
handler = new LoginSuccessHandler(loginAttemptService, clientInfoResolver, auditLogService,
userRepository);
user = new SecurityUser(7L, "user1", null, "测试用户", "660001",
"110101199001011234", "13800138000", "user@example.com", "USER", true);
}
@Test
void bindsPasswordLoginIpAndAuditsLoginType() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/login");
MockHttpSession session = new MockHttpSession();
request.setSession(session);
when(clientInfoResolver.resolveIp(request)).thenReturn("10.0.0.1");
when(clientInfoResolver.resolveUserAgent(request)).thenReturn("UA");
handler.onAuthenticationSuccess(request, new MockHttpServletResponse(),
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()));
assertEquals("10.0.0.1", session.getAttribute(LoginSession.IP_ATTRIBUTE));
assertEquals(LoginType.PASSWORD.name(), session.getAttribute(LoginSession.TYPE_ATTRIBUTE));
verify(auditLogService).recordLoginSuccess(user, "10.0.0.1", "UA",
"登录成功(账号密码)", LoginType.PASSWORD);
}
@Test
void bindsCertificateLoginIpAndAuditsLoginType() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/login/cert/authenticate");
MockHttpSession session = new MockHttpSession();
request.setSession(session);
request.setAttribute(LoginSuccessHandler.LOGIN_TYPE_ATTRIBUTE, LoginType.CERTIFICATE.name());
request.setAttribute(LoginSuccessHandler.LOGIN_DETAIL_ATTRIBUTE, "登录成功(数字证书)");
when(clientInfoResolver.resolveIp(request)).thenReturn("10.0.0.2");
when(clientInfoResolver.resolveUserAgent(request)).thenReturn("UA");
handler.onAuthenticationSuccess(request, new MockHttpServletResponse(),
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()));
assertEquals("10.0.0.2", session.getAttribute(LoginSession.IP_ATTRIBUTE));
assertEquals(LoginType.CERTIFICATE.name(), session.getAttribute(LoginSession.TYPE_ATTRIBUTE));
verify(auditLogService).recordLoginSuccess(user, "10.0.0.2", "UA",
"登录成功(数字证书)", LoginType.CERTIFICATE);
}
@Test
void redirectsImportedUserToChangePassword() throws Exception {
SecurityUser imported = new SecurityUser(7L, "user1", null, "测试用户", "660001",
"110101199001011234", "13800138000", "user@example.com", "USER", true, true);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/login");
request.setSession(new MockHttpSession());
MockHttpServletResponse response = new MockHttpServletResponse();
when(clientInfoResolver.resolveIp(request)).thenReturn("10.0.0.1");
when(clientInfoResolver.resolveUserAgent(request)).thenReturn("UA");
handler.onAuthenticationSuccess(request, response,
new UsernamePasswordAuthenticationToken(imported, null, imported.getAuthorities()));
assertEquals("/change-password", response.getRedirectedUrl());
}
}
package com.zhihui.portal.user;
import com.zhihui.portal.audit.AuditEvents;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import com.zhihui.portal.security.SessionRevocationService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AccountInactivityServiceTest {
@Mock
private SysUserRepository userRepository;
@Mock
private AuditLogService auditLogService;
@Mock
private SessionRevocationService sessionRevocationService;
@Mock
private OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
@Test
void disablesImportedAndGeneralAccountsWithoutDuplicateWrites() {
LocalDateTime now = LocalDateTime.of(2026, 11, 3, 0, 0);
SysUser imported = user(1L, "u1");
imported.setImported(true);
SysUser general = user(2L, "u2");
AccountInactivityService service = new AccountInactivityService(
userRepository, auditLogService, sessionRevocationService,
oauthAuthorizationRevocationService);
when(userRepository.findInactiveImported(now.minusDays(30))).thenReturn(List.of(imported));
when(userRepository.findInactiveExceptAdmin(now.minusDays(90)))
.thenReturn(List.of(imported, general));
when(userRepository.save(any(SysUser.class))).thenAnswer(invocation -> invocation.getArgument(0));
AccountInactivityService.InactivityResult result = service.disableInactive(now);
assertEquals(1, result.importedDisabled());
assertEquals(1, result.generalDisabled());
assertEquals(0, imported.getStatus());
assertEquals(0, general.getStatus());
verify(userRepository, times(2)).save(any(SysUser.class));
verify(sessionRevocationService).revokeByUsername("u1");
verify(sessionRevocationService).revokeByUsername("u2");
verify(oauthAuthorizationRevocationService).revokeByPrincipalName("u1");
verify(oauthAuthorizationRevocationService).revokeByPrincipalName("u2");
verify(auditLogService, times(2)).recordSystem(
eq(AuditEvents.USER_AUTO_DISABLE), eq("USER"), anyString(), anyString());
}
private static SysUser user(Long id, String username) {
SysUser user = new SysUser();
user.setId(id);
user.setUsername(username);
user.setStatus(1);
return user;
}
}
package com.zhihui.portal.user;
import com.zhihui.portal.common.BizException;
import com.zhihui.portal.user.dto.UserDtos.ImportUserResult;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
class UserImportServiceTest {
@Test
void templateMarksIdentifierColumnsAsText() throws Exception {
UserImportService service = new UserImportService(null, null);
try (XSSFWorkbook workbook = new XSSFWorkbook(
new ByteArrayInputStream(service.template()))) {
Sheet sheet = workbook.getSheet("用户导入");
assertNotNull(sheet);
for (int column : new int[]{0, 1, 3, 4, 6}) {
CellStyle style = sheet.getColumnStyle(column);
assertNotNull(style, "column " + column);
assertEquals("@", style.getDataFormatString(), "column " + column);
}
}
}
@Test
void importRejectsNumericIdCardCell() throws Exception {
byte[] bytes;
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("用户导入");
String[] headers = {"用户名", "密码", "姓名", "警号", "身份证号", "邮箱", "手机号", "角色", "状态"};
Row header = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
header.createCell(i).setCellValue(headers[i]);
}
Row row = sheet.createRow(1);
row.createCell(0).setCellValue("u1");
row.createCell(1).setCellValue("Password1");
row.createCell(2).setCellValue("张三");
row.createCell(3).setCellValue("000002");
row.createCell(4).setCellValue(110101199001011234d);
row.createCell(5).setCellValue("u1@example.com");
row.createCell(6).setCellValue("13800138000");
row.createCell(7).setCellValue("USER");
row.createCell(8).setCellValue("1");
workbook.write(output);
bytes = output.toByteArray();
}
UserImportService service = new UserImportService(mock(UserService.class), mock(Validator.class));
MockMultipartFile file = new MockMultipartFile("file", "users.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", bytes);
ImportUserResult result = service.importUsers(file);
assertEquals(1, result.total());
assertEquals(0, result.success());
assertEquals(1, result.failed());
assertTrue(result.errors().get(0).message().contains("身份证号列必须为文本格式"));
}
@Test
void importRequiresIdCardAndPhone() throws Exception {
String[] headers = {"用户名", "密码", "姓名", "警号", "身份证号", "邮箱", "手机号", "角色", "状态"};
String[][] rows = {
{"u1", "Password1", "张三", "P001", null, "u1@example.com", "13800138001", "USER", "1"},
{"u2", "Password1", "李四", "P002", "110101199001011234", null, null, "USER", "1"}
};
UserImportService service = new UserImportService(mock(UserService.class), mock(Validator.class));
ImportUserResult result = service.importUsers(workbookFile(headers, rows));
assertEquals(2, result.total());
assertEquals(0, result.success());
assertEquals(2, result.failed());
assertTrue(result.errors().get(0).message().contains("身份证号不能为空"));
assertTrue(result.errors().get(1).message().contains("手机号不能为空"));
}
@Test
void importReportsIdCardAndPhoneFormatErrors() throws Exception {
String[] headers = {"用户名", "密码", "姓名", "警号", "身份证号", "邮箱", "手机号", "角色", "状态"};
String[][] rows = {
{"u1", "Password1", "张三", "P001", "123", "u1@example.com", "13800138001", "USER", "1"},
{"u2", "Password1", "李四", "P002", "110101199001011234", "u2@example.com", "12345", "USER", "1"}
};
UserService userService = mock(UserService.class);
Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
UserImportService service = new UserImportService(userService, validator);
ImportUserResult result = service.importUsers(workbookFile(headers, rows));
assertEquals(2, result.total());
assertEquals(0, result.success());
assertEquals(2, result.failed());
assertTrue(result.errors().get(0).message().contains("身份证号需为 18 位"));
assertTrue(result.errors().get(1).message().contains("手机号需为 11 位中国大陆手机号"));
verifyNoInteractions(userService);
}
@Test
void importRequiresIdentifierColumns() throws Exception {
String[] headers = {"用户名", "密码", "姓名", "警号", "邮箱", "角色", "状态"};
String[][] rows = {{"u1", "Password1", "张三", "P001", "u1@example.com", "USER", "1"}};
UserImportService service = new UserImportService(mock(UserService.class), mock(Validator.class));
BizException error = assertThrows(BizException.class,
() -> service.importUsers(workbookFile(headers, rows)));
assertTrue(error.getMessage().contains("身份证号"));
assertTrue(error.getMessage().contains("手机号"));
}
private static MockMultipartFile workbookFile(String[] headers, String[][] rows) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("用户导入");
Row header = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
header.createCell(i).setCellValue(headers[i]);
}
for (int rowIndex = 0; rowIndex < rows.length; rowIndex++) {
Row row = sheet.createRow(rowIndex + 1);
for (int column = 0; column < rows[rowIndex].length; column++) {
String value = rows[rowIndex][column];
if (value != null) {
row.createCell(column).setCellValue(value);
}
}
}
workbook.write(output);
return new MockMultipartFile("file", "users.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
output.toByteArray());
}
}
}
......@@ -2,12 +2,15 @@ package com.zhihui.portal.user;
import com.zhihui.portal.common.BizException;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import com.zhihui.portal.security.SessionRevocationService;
import com.zhihui.portal.user.dto.UserDtos.CreateUserRequest;
import com.zhihui.portal.user.dto.UserDtos.UpdateUserRequest;
import com.zhihui.portal.user.dto.UserDtos.UserResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
......@@ -16,8 +19,10 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
......@@ -36,11 +41,18 @@ class UserServiceTest {
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private SessionRevocationService sessionRevocationService;
@Mock
private OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
private UserService userService;
@BeforeEach
void setUp() {
userService = new UserService(userRepository, passwordEncoder, jdbcTemplate);
userService = new UserService(userRepository, passwordEncoder, jdbcTemplate,
sessionRevocationService, oauthAuthorizationRevocationService);
}
@Test
......@@ -185,6 +197,7 @@ class UserServiceTest {
1L, "admin", "encoded", "系统管理员", null, null, null, null, "ADMIN", true);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
when(userRepository.findById(1L)).thenReturn(Optional.of(adminActor(admin)));
when(userRepository.existsByUsernameAndIdNot("test-renamed", 2L)).thenReturn(false);
when(userRepository.save(any(SysUser.class))).thenAnswer(invocation -> invocation.getArgument(0));
......@@ -226,6 +239,7 @@ class UserServiceTest {
1L, "admin", "encoded", "系统管理员", null, null, null, null, "ADMIN", true);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
when(userRepository.findById(1L)).thenReturn(Optional.of(adminActor(admin)));
BizException error = assertThrows(BizException.class, () -> userService.update(
2L, new UpdateUserRequest("admin", "测试用户", "FJ001", null, null, null, "USER", 1), admin));
......@@ -246,6 +260,7 @@ class UserServiceTest {
1L, "admin", "encoded", "系统管理员", null, null, null, null, "ADMIN", true);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
when(userRepository.findById(1L)).thenReturn(Optional.of(adminActor(admin)));
when(userRepository.existsByUsernameAndIdNot("taken", 2L)).thenReturn(true);
BizException error = assertThrows(BizException.class, () -> userService.update(
......@@ -253,4 +268,67 @@ class UserServiceTest {
assertEquals("用户名已存在", error.getMessage());
verify(userRepository, never()).save(any(SysUser.class));
}
@Test
void importedUserGetsForcedPasswordChangeFlags() {
when(userRepository.existsByUsername("user1")).thenReturn(false);
when(passwordEncoder.encode("Password1")).thenReturn("encoded");
when(userRepository.save(any(SysUser.class))).thenAnswer(invocation -> invocation.getArgument(0));
userService.createImported(new CreateUserRequest(
"user1", "Password1", "张三", "PN001", null, null, null, "USER"), 1);
ArgumentCaptor<SysUser> captor = ArgumentCaptor.forClass(SysUser.class);
verify(userRepository).save(captor.capture());
assertTrue(captor.getValue().isMustChangePassword());
assertTrue(captor.getValue().isImported());
}
@Test
void changeOwnPasswordRejectsOriginalPassword() {
SysUser user = new SysUser();
user.setId(7L);
user.setUsername("user1");
user.setStatus(1);
user.setPassword("encoded");
when(userRepository.findById(7L)).thenReturn(Optional.of(user));
when(passwordEncoder.matches("OldPass1", "encoded")).thenReturn(true);
BizException error = assertThrows(BizException.class,
() -> userService.changeOwnPassword(7L, "OldPass1", "OldPass1"));
assertEquals("新密码不能与原密码相同", error.getMessage());
}
@Test
void changeOwnPasswordClearsForcedFlag() {
SysUser user = new SysUser();
user.setId(7L);
user.setUsername("user1");
user.setStatus(1);
user.setPassword("encoded");
user.setMustChangePassword(true);
when(userRepository.findById(7L)).thenReturn(Optional.of(user));
when(passwordEncoder.matches("OldPass1", "encoded")).thenReturn(true);
when(passwordEncoder.matches("NewPass1", "encoded")).thenReturn(false);
when(passwordEncoder.encode("NewPass1")).thenReturn("encoded-new");
when(userRepository.save(any(SysUser.class))).thenAnswer(invocation -> invocation.getArgument(0));
userService.changeOwnPassword(7L, "OldPass1", "NewPass1");
assertFalse(user.isMustChangePassword());
assertEquals("encoded-new", user.getPassword());
verify(userRepository).save(user);
verify(sessionRevocationService).revokeByUsername("user1");
verify(oauthAuthorizationRevocationService).revokeByPrincipalName("user1");
}
private static SysUser adminActor(SecurityUser admin) {
SysUser actor = new SysUser();
actor.setId(admin.getId());
actor.setUsername(admin.getUsername());
actor.setRole(admin.getRole());
actor.setStatus(1);
return actor;
}
}
......@@ -50,6 +50,8 @@ class OidcDiscovery {
required(json, "authorization_endpoint"),
required(json, "token_endpoint"),
required(json, "jwks_uri"),
text(json, "userinfo_endpoint",
trimTrailingSlash(properties.getIssuer()) + "/userinfo"),
text(json, "end_session_endpoint", null));
this.metadata = loaded;
return loaded;
......@@ -84,6 +86,6 @@ class OidcDiscovery {
}
record Metadata(String issuer, String authorizationEndpoint, String tokenEndpoint,
String jwksUri, String endSessionEndpoint) {
String jwksUri, String userInfoEndpoint, String endSessionEndpoint) {
}
}
......@@ -93,6 +93,9 @@ public class SsoAuthenticationFilter extends OncePerRequestFilter {
SsoUser user = SsoSession.getUser(request);
if (user != null) {
if (!validateSession(request, response)) {
return;
}
if (path.equals(loginPath())) {
redirectAfterLogin(request, response);
return;
......@@ -220,6 +223,10 @@ public class SsoAuthenticationFilter extends OncePerRequestFilter {
if (StringUtils.hasText(tokens.accessToken())) {
session.setAttribute(SsoSession.ACCESS_TOKEN_ATTRIBUTE, tokens.accessToken());
}
if (StringUtils.hasText(tokens.refreshToken())) {
session.setAttribute(SsoSession.REFRESH_TOKEN_ATTRIBUTE, tokens.refreshToken());
}
session.setAttribute(SsoSession.LAST_VALIDATED_ATTRIBUTE, System.currentTimeMillis());
log.info("统一认证登录成功: user={}, sub={}", user.getUsername(), user.getSubject());
String target = safeLocalPath(savedRequest);
......@@ -227,6 +234,161 @@ public class SsoAuthenticationFilter extends OncePerRequestFilter {
request.getContextPath() + (target != null ? target : "/")));
}
/**
* 已有本地会话在固定间隔后通过 UserInfo 校验一次;令牌过期时尝试刷新,失败则重新登录。
* 这样门户停用、删除用户或撤销授权后,子系统不会继续使用旧会话到 8 小时。
*/
private boolean validateSession(HttpServletRequest request, HttpServletResponse response)
throws IOException {
HttpSession session = request.getSession(false);
Duration interval = properties.getSessionRevalidateInterval();
if (session == null || interval == null || interval.isZero() || interval.isNegative()) {
return true;
}
long now = System.currentTimeMillis();
Object lastValidated = session.getAttribute(SsoSession.LAST_VALIDATED_ATTRIBUTE);
if (lastValidated instanceof Long last && now - last >= 0
&& now - last < interval.toMillis()) {
return true;
}
String accessToken = SsoSession.getAccessToken(request);
if (!StringUtils.hasText(accessToken)) {
session.setAttribute(SsoSession.LAST_VALIDATED_ATTRIBUTE, now);
return true;
}
OidcDiscovery.Metadata metadata;
try {
metadata = discovery.metadata();
} catch (Exception ex) {
log.warn("UserInfo 校验前获取认证中心元数据失败: {}", ex.getMessage());
session.setAttribute(SsoSession.LAST_VALIDATED_ATTRIBUTE, now);
return true;
}
String userInfoEndpoint = metadata.userInfoEndpoint();
if (!StringUtils.hasText(userInfoEndpoint)) {
session.setAttribute(SsoSession.LAST_VALIDATED_ATTRIBUTE, now);
return true;
}
int status;
try {
HttpRequest userInfoRequest = HttpRequest.newBuilder(URI.create(userInfoEndpoint))
.timeout(requestTimeout())
.header("Authorization", "Bearer " + accessToken)
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> userInfoResponse = httpClient.send(userInfoRequest,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
status = userInfoResponse.statusCode();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
log.warn("UserInfo 校验被中断,暂时保留本地会话");
session.setAttribute(SsoSession.LAST_VALIDATED_ATTRIBUTE, now);
return true;
} catch (Exception ex) {
log.warn("UserInfo 校验请求失败,暂时保留本地会话: {}", ex.getMessage());
session.setAttribute(SsoSession.LAST_VALIDATED_ATTRIBUTE, now);
return true;
}
if (status >= 200 && status < 300) {
session.setAttribute(SsoSession.LAST_VALIDATED_ATTRIBUTE, now);
return true;
}
if (status == 401 || status == 403) {
if (refreshSession(metadata, session)) {
return true;
}
invalidateAndReauthenticate(request, response);
return false;
}
log.warn("UserInfo 校验返回 HTTP {},暂时保留本地会话", status);
session.setAttribute(SsoSession.LAST_VALIDATED_ATTRIBUTE, now);
return true;
}
private boolean refreshSession(OidcDiscovery.Metadata metadata, HttpSession session) {
String refreshToken = (String) session.getAttribute(SsoSession.REFRESH_TOKEN_ATTRIBUTE);
if (!StringUtils.hasText(refreshToken)) {
return false;
}
StringBuilder form = new StringBuilder();
appendForm(form, "grant_type", "refresh_token", true);
appendForm(form, "refresh_token", refreshToken, false);
String credentials = formEncode(properties.getClientId()) + ":"
+ formEncode(properties.getClientSecret());
String authorization = "Basic " + Base64.getEncoder()
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
try {
HttpRequest tokenRequest = HttpRequest.newBuilder(URI.create(metadata.tokenEndpoint()))
.timeout(requestTimeout())
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.header("Authorization", authorization)
.POST(HttpRequest.BodyPublishers.ofString(form.toString(), StandardCharsets.UTF_8))
.build();
HttpResponse<String> tokenResponse = httpClient.send(tokenRequest,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (tokenResponse.statusCode() != 200) {
log.debug("刷新令牌失败, HTTP {}", tokenResponse.statusCode());
return false;
}
JsonNode json = objectMapper.readTree(tokenResponse.body());
String idToken = json.path("id_token").asText(null);
if (!StringUtils.hasText(idToken)) {
return false;
}
JWTClaimsSet claims = idTokenVerifier.verify(idToken, metadata, properties.getClientId());
SsoUser refreshed = toUser(claims);
session.setAttribute(SsoSession.USER_ATTRIBUTE, refreshed);
session.setAttribute(SsoSession.ID_TOKEN_ATTRIBUTE, idToken);
String accessToken = json.path("access_token").asText(null);
if (StringUtils.hasText(accessToken)) {
session.setAttribute(SsoSession.ACCESS_TOKEN_ATTRIBUTE, accessToken);
}
String rotatedRefreshToken = json.path("refresh_token").asText(null);
if (StringUtils.hasText(rotatedRefreshToken)) {
session.setAttribute(SsoSession.REFRESH_TOKEN_ATTRIBUTE, rotatedRefreshToken);
}
session.setAttribute(SsoSession.LAST_VALIDATED_ATTRIBUTE, System.currentTimeMillis());
return true;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return false;
} catch (Exception ex) {
log.debug("刷新令牌失败: {}", ex.getMessage());
return false;
}
}
private void invalidateAndReauthenticate(HttpServletRequest request, HttpServletResponse response)
throws IOException {
HttpSession session = request.getSession(false);
if (session != null) {
try {
session.invalidate();
} catch (IllegalStateException ignored) {
// 会话已经失效时忽略
}
}
if (isHtmlNavigation(request)) {
beginLogin(request, response, path(request));
return;
}
writeUnauthorized(request, response);
}
private Duration requestTimeout() {
return properties.getReadTimeout() == null
? Duration.ofSeconds(10) : properties.getReadTimeout();
}
private void handleLogout(HttpServletRequest request, HttpServletResponse response) throws IOException {
HttpSession session = request.getSession(false);
String idToken = null;
......@@ -303,7 +465,8 @@ public class SsoAuthenticationFilter extends OncePerRequestFilter {
if (!StringUtils.hasText(idToken)) {
throw new SsoException("统一认证未返回 ID Token");
}
return new TokenResponse(idToken, json.path("access_token").asText(null));
return new TokenResponse(idToken, json.path("access_token").asText(null),
json.path("refresh_token").asText(null));
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new SsoException("令牌交换被中断");
......@@ -545,6 +708,6 @@ public class SsoAuthenticationFilter extends OncePerRequestFilter {
}
}
private record TokenResponse(String idToken, String accessToken) {
private record TokenResponse(String idToken, String accessToken, String refreshToken) {
}
}
......@@ -36,6 +36,9 @@ public class SsoProperties {
private Duration readTimeout = Duration.ofSeconds(10);
/** 已登录会话通过 UserInfo 重新校验的间隔;设为 0 可关闭 */
private Duration sessionRevalidateInterval = Duration.ofSeconds(60);
private String loginPath = "/sso/login";
private String callbackPath = "/sso/callback";
......@@ -122,6 +125,14 @@ public class SsoProperties {
this.readTimeout = readTimeout;
}
public Duration getSessionRevalidateInterval() {
return sessionRevalidateInterval;
}
public void setSessionRevalidateInterval(Duration sessionRevalidateInterval) {
this.sessionRevalidateInterval = sessionRevalidateInterval;
}
public String getLoginPath() {
return loginPath;
}
......
......@@ -8,6 +8,8 @@ public final class SsoSession {
public static final String USER_ATTRIBUTE = "ZHIHUI_SSO_USER";
public static final String ACCESS_TOKEN_ATTRIBUTE = "ZHIHUI_SSO_ACCESS_TOKEN";
static final String REFRESH_TOKEN_ATTRIBUTE = "ZHIHUI_SSO_REFRESH_TOKEN";
static final String LAST_VALIDATED_ATTRIBUTE = "ZHIHUI_SSO_LAST_VALIDATED_AT";
static final String ID_TOKEN_ATTRIBUTE = "ZHIHUI_SSO_ID_TOKEN";
static final String STATE_ATTRIBUTE = "ZHIHUI_SSO_STATE";
static final String NONCE_ATTRIBUTE = "ZHIHUI_SSO_NONCE";
......
......@@ -13,7 +13,8 @@ http.interceptors.response.use(
(error) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
if (!window.location.pathname.startsWith('/login')) {
window.location.href = '/login'
const data = error.response.data as { code?: string } | undefined
window.location.href = data?.code === 'LOGIN_IP_CHANGED' ? '/login?error=ip_changed' : '/login'
}
}
return Promise.reject(error)
......
......@@ -105,12 +105,14 @@ export interface PageResult<T> {
export interface AuditLogEntry {
id: number
eventType: string
loginType: string | null
userId: number | null
username: string | null
clientIp: string | null
userAgent: string | null
targetType: string | null
targetId: string | null
targetName: string | null
detail: string | null
success: boolean
createdAt: string
......@@ -120,6 +122,7 @@ export interface LoginRecord {
id: number
clientIp: string | null
userAgent: string | null
loginType: string | null
createdAt: string
}
......@@ -204,7 +207,7 @@ export const adminApi = {
importUsers: (file: File) => {
const form = new FormData()
form.append('file', file)
return http.post<ImportUserResult>('/api/admin/users/import', form, { timeout: 120000 })
return http.post<ImportUserResult>('/api/admin/users/import', form, { timeout: 900000 })
},
downloadUserTemplate: () =>
http.get<Blob>('/api/admin/users/import-template', { responseType: 'blob' }),
......
<template>
<el-dialog
v-model="visible"
class="portal-dark-dialog"
:title="createdSecret ? '接入密钥' : props.app ? '编辑应用' : '新增应用'"
width="min(640px, 94vw)"
destroy-on-close
@closed="createdSecret = null"
>
<template v-if="!createdSecret">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<div class="form-grid">
<el-form-item label="应用名称" prop="appName">
<el-input v-model="form.appName" maxlength="100" placeholder="例如:案件管理" />
</el-form-item>
<el-form-item label="应用编码" prop="appCode">
<el-input
v-model="form.appCode"
:disabled="!!props.app"
maxlength="32"
placeholder="例如:case-center"
/>
</el-form-item>
</div>
<el-form-item label="访问地址" prop="appUrl">
<el-input v-model="form.appUrl" placeholder="例如:http://localhost:8081" />
</el-form-item>
<el-form-item label="应用图标">
<AppIconPicker v-model="form.iconUrl" />
</el-form-item>
<el-form-item label="自定义图标地址">
<el-input v-model="form.iconUrl" placeholder="也可填写图片 URL,留空则使用文字图标" />
</el-form-item>
<p class="form-hint">创建后可在门户首页拖动图标调整显示顺序。</p>
<el-form-item label="应用描述">
<el-input
v-model="form.description"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="一句话说明这个系统提供什么能力"
/>
</el-form-item>
<el-form-item label="回调地址">
<el-input
v-model="form.redirectUrisText"
type="textarea"
:rows="2"
placeholder="每行一个,留空则默认使用 {访问地址}/sso/callback"
/>
</el-form-item>
<el-form-item label="登出回调地址">
<el-input
v-model="form.postLogoutRedirectUrisText"
type="textarea"
:rows="2"
placeholder="每行一个,留空则默认使用 {访问地址}/"
/>
</el-form-item>
</el-form>
</template>
<template v-else>
<div class="secret-row">
<span>client_id</span>
<div class="secret-value">
<code>{{ createdSecret.clientId }}</code>
<el-button link :icon="CopyDocument" @click="copy(createdSecret.clientId)">复制</el-button>
</div>
</div>
<div class="secret-row">
<span>client_secret</span>
<div class="secret-value">
<code>{{ createdSecret.clientSecret }}</code>
<el-button link :icon="CopyDocument" @click="copy(createdSecret.clientSecret)">复制</el-button>
</div>
</div>
<div class="secret-row">
<span>默认回调</span>
<div class="secret-value">
<code>{{ createdSecret.redirectUri }}</code>
</div>
</div>
</template>
<template #footer>
<template v-if="!createdSecret">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</template>
<template v-else>
<el-button :icon="CopyDocument" @click="copy(createdSecretText())">复制全部配置</el-button>
<el-button type="primary" @click="visible = false">关闭</el-button>
</template>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { CopyDocument } from '@element-plus/icons-vue'
import AppIconPicker from '@/components/AppIconPicker.vue'
import {
adminApi,
type AdminApp,
type CreateAppPayload,
type UpdateAppPayload,
} from '@/api/portal'
import { apiMessage } from '@/api/http'
const props = withDefaults(
defineProps<{ modelValue: boolean; app: AdminApp | null; nextSortOrder?: number }>(),
{ nextSortOrder: 10 },
)
const emit = defineEmits<{ 'update:modelValue': [boolean]; saved: [] }>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const formRef = ref<FormInstance>()
const saving = ref(false)
const createdSecret = ref<{ clientId: string; clientSecret: string; redirectUri: string } | null>(null)
const form = reactive({
appCode: '',
appName: '',
appUrl: '',
iconUrl: '',
description: '',
redirectUrisText: '',
postLogoutRedirectUrisText: '',
})
const rules: FormRules = {
appCode: [
{ required: true, message: '请输入应用编码', trigger: 'blur' },
{
pattern: /^[a-z][a-z0-9-]{2,31}$/,
message: '3-32 位小写字母、数字或中划线,且以字母开头',
trigger: 'blur',
},
],
appName: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
appUrl: [
{ required: true, message: '请输入访问地址', trigger: 'blur' },
{ pattern: /^https?:\/\/.+/, message: '需以 http:// 或 https:// 开头', trigger: 'blur' },
],
}
function initForm() {
createdSecret.value = null
const app = props.app
Object.assign(form, {
appCode: app?.appCode ?? '',
appName: app?.appName ?? '',
appUrl: app?.appUrl ?? '',
iconUrl: app?.iconUrl ?? '',
description: app?.description ?? '',
redirectUrisText: (app?.redirectUris ?? []).join('\n'),
postLogoutRedirectUrisText: (app?.postLogoutRedirectUris ?? []).join('\n'),
})
nextTick(() => formRef.value?.clearValidate())
}
watch(
() => props.modelValue,
(open) => {
if (open) initForm()
},
)
watch(
() => props.app,
() => {
if (props.modelValue) initForm()
},
)
function splitLines(text: string) {
return text
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
async function submit() {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
const payload = {
appName: form.appName.trim(),
appUrl: form.appUrl.trim(),
iconUrl: form.iconUrl.trim(),
description: form.description.trim(),
redirectUris: splitLines(form.redirectUrisText),
postLogoutRedirectUris: splitLines(form.postLogoutRedirectUrisText),
}
saving.value = true
try {
if (props.app) {
const update: UpdateAppPayload = {
...payload,
sortOrder: props.app.sortOrder ?? 0,
status: props.app.status ?? 1,
}
await adminApi.updateApp(props.app.id, update)
ElMessage.success('应用已更新')
emit('saved')
visible.value = false
} else {
const create: CreateAppPayload = {
...payload,
appCode: form.appCode.trim(),
sortOrder: props.nextSortOrder,
}
const { data } = await adminApi.createApp(create)
createdSecret.value = {
clientId: data.app.clientId,
clientSecret: data.clientSecret,
redirectUri: data.app.redirectUris[0] ?? `${data.app.appUrl}/sso/callback`,
}
ElMessage.success('应用已创建')
emit('saved')
}
} catch (error) {
ElMessage.error(apiMessage(error))
} finally {
saving.value = false
}
}
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败,请手动选中复制')
}
}
function createdSecretText() {
const info = createdSecret.value
if (!info) return ''
return [
`client_id: ${info.clientId}`,
`client_secret: ${info.clientSecret}`,
`redirect_uri: ${info.redirectUri}`,
].join('\n')
}
</script>
<style scoped>
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 18px;
}
.form-hint {
margin: -4px 0 18px;
font-size: 12px;
color: rgba(220, 231, 243, 0.5);
}
.secret-row {
display: grid;
grid-template-columns: 104px minmax(0, 1fr);
align-items: center;
gap: 14px;
padding: 12px 0;
border-bottom: 1px dashed rgba(169, 201, 232, 0.14);
font-size: 12px;
}
.secret-row:last-child {
border-bottom: none;
}
.secret-row > span {
color: rgba(220, 231, 243, 0.5);
}
.secret-value {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.secret-value code {
overflow: hidden;
font-family: var(--mono);
font-size: 12px;
color: #dce7f3;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 640px) {
.form-grid {
grid-template-columns: 1fr;
}
.secret-row {
grid-template-columns: 1fr;
gap: 5px;
}
}
</style>
......@@ -2,7 +2,7 @@
<el-dialog
v-model="visible"
class="portal-dark-dialog"
title="应用市场"
title="智能体广场"
width="min(760px, 94vw)"
destroy-on-close
@open="load"
......@@ -54,8 +54,8 @@
</div>
<div v-else-if="!loading" class="market-empty">
<p>{{ keyword.trim() ? '没有匹配的应用' : '应用市场还没有上架应用' }}</p>
<span v-if="!keyword.trim()">请联系管理员在“市场管理”中接入子系统。</span>
<p>{{ keyword.trim() ? '没有匹配的应用' : '智能体广场还没有上架应用' }}</p>
<span v-if="!keyword.trim()">请联系管理员在“智能体广场管理”中接入子系统。</span>
</div>
</div>
......
<template>
<div class="app-item" :class="{ 'is-managing': manage }" @contextmenu="handleContextMenu">
<component
:is="linkable ? 'a' : 'div'"
<div class="app-item" @contextmenu="handleContextMenu">
<a
class="app-link"
:href="linkable ? app.appUrl : undefined"
:target="linkable ? '_blank' : undefined"
:rel="linkable ? 'noopener' : undefined"
:title="linkable ? app.description || app.appName : undefined"
:draggable="linkable ? 'false' : undefined"
:href="app.appUrl"
target="_blank"
rel="noopener"
:title="app.description || app.appName"
draggable="false"
>
<span class="app-icon" :style="{ background: tone.bg, color: tone.fg }">
<img v-if="app.iconUrl" :src="app.iconUrl" alt="" />
<span v-else>{{ initials }}</span>
</span>
<span class="app-name">{{ app.appName }}</span>
</component>
<span v-if="manage" class="app-actions">
<button type="button" class="action" title="编辑应用" @click.stop="emit('edit')">
<el-icon><EditPen /></el-icon>
</button>
<button type="button" class="action" title="从我的门户移除" @click.stop="emit('remove')">
<el-icon><Remove /></el-icon>
</button>
</span>
</a>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { EditPen, Remove } from '@element-plus/icons-vue'
import type { PortalApp } from '@/api/portal'
import { appInitials, appTone } from '@/utils/appTone'
const props = withDefaults(
defineProps<{
app: PortalApp
manage?: boolean
linkable?: boolean
}>(),
{ manage: false, linkable: true },
)
const emit = defineEmits<{ edit: []; remove: []; menu: [app: PortalApp, event: MouseEvent] }>()
const props = defineProps<{ app: PortalApp }>()
const emit = defineEmits<{ menu: [app: PortalApp, event: MouseEvent] }>()
function handleContextMenu(event: MouseEvent) {
if (props.manage) {
return
}
event.preventDefault()
event.stopPropagation()
emit('menu', props.app, event)
......@@ -60,9 +38,9 @@ const initials = computed(() => appInitials(props.app.appName))
<style scoped>
.app-item {
position: relative;
width: 112px;
width: clamp(104px, 7.4vw, 140px);
flex: none;
padding: 10px 6px 12px;
padding: clamp(9px, 0.7vw, 13px) clamp(6px, 0.5vw, 8px) clamp(11px, 0.8vw, 15px);
border-radius: 12px;
user-select: none;
-webkit-user-drag: none;
......@@ -73,19 +51,11 @@ const initials = computed(() => appInitials(props.app.appName))
transform: translateY(-3px);
}
.app-item.is-managing {
cursor: default;
}
.app-item.is-managing:hover {
transform: none;
}
.app-link {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
gap: clamp(10px, 0.9vw, 16px);
color: inherit;
text-decoration: none;
}
......@@ -94,14 +64,14 @@ const initials = computed(() => appInitials(props.app.appName))
position: relative;
display: grid;
place-items: center;
width: 84px;
height: 84px;
width: clamp(62px, 4.64vw, 88px);
height: clamp(62px, 4.64vw, 88px);
overflow: hidden;
font-family: var(--display);
font-size: 28px;
font-size: clamp(21px, 1.52vw, 28px);
font-weight: 600;
letter-spacing: 0.04em;
border-radius: 20px;
border-radius: clamp(15px, 1.12vw, 21px);
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.44),
0 0 0 1px rgba(169, 201, 232, 0.16);
......@@ -124,12 +94,6 @@ const initials = computed(() => appInitials(props.app.appName))
0 0 0 1px rgba(169, 201, 232, 0.32);
}
.app-item.is-managing:hover .app-icon {
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.44),
0 0 0 1px rgba(169, 201, 232, 0.16);
}
.app-icon img {
width: 100%;
height: 100%;
......@@ -137,9 +101,9 @@ const initials = computed(() => appInitials(props.app.appName))
}
.app-name {
max-width: 112px;
max-width: clamp(104px, 7.4vw, 140px);
overflow: hidden;
font-size: 13px;
font-size: clamp(12px, 0.9vw, 15px);
line-height: 1.5;
color: rgba(220, 231, 243, 0.82);
text-align: center;
......@@ -157,42 +121,6 @@ const initials = computed(() => appInitials(props.app.appName))
outline-offset: 4px;
}
.app-actions {
position: absolute;
top: 5px;
right: 5px;
display: flex;
gap: 4px;
}
.action {
display: grid;
place-items: center;
width: 22px;
height: 22px;
padding: 0;
color: #cfe2f3;
background: rgba(8, 32, 60, 0.94);
border: 1px solid rgba(169, 201, 232, 0.26);
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.36);
cursor: pointer;
transition: background 0.14s ease, transform 0.14s ease;
}
.action:hover {
background: rgba(17, 83, 155, 0.9);
transform: scale(1.06);
}
.action.is-danger {
color: #f0a4aa;
}
.action .el-icon {
font-size: 12px;
}
@media (max-width: 520px) {
.app-item {
width: 92px;
......
<template>
<div
class="portal"
:class="{ 'is-dark-portal': isDarkPortal, 'is-admin-area': isAdminArea }"
:class="{ 'is-dark-portal': isDarkPortal, 'is-admin-area': isAdminArea, 'is-light-app-center': isAppCenter }"
>
<div v-if="isDarkPortal" class="portal-grid" aria-hidden="true"></div>
<header class="masthead">
......@@ -175,6 +175,11 @@ onBeforeUnmount(() => {
z-index: 1;
}
.portal.is-light-app-center .portal-main {
color: #1f2937;
background: #ffffff;
}
.masthead {
position: sticky;
top: 0;
......@@ -189,24 +194,24 @@ onBeforeUnmount(() => {
.masthead-inner {
display: flex;
align-items: center;
gap: 24px;
max-width: 1280px;
height: 73px;
gap: clamp(18px, 1.6vw, 28px);
max-width: clamp(1280px, 80vw, 1800px);
height: clamp(68px, 4.6vw, 92px);
margin: 0 auto;
padding: 0 40px;
padding: 0 var(--page-pad-x);
}
.brand {
display: flex;
align-items: center;
gap: 15px;
gap: clamp(14px, 1.2vw, 22px);
text-decoration: none;
}
.brand-mark {
display: block;
width: 48px;
height: 48px;
width: clamp(53px, 3.6vw, 70px);
height: clamp(53px, 3.6vw, 70px);
object-fit: contain;
}
......@@ -218,14 +223,14 @@ onBeforeUnmount(() => {
.brand-name {
font-family: var(--display);
font-size: 23px;
font-size: clamp(25px, 1.8vw, 32px);
letter-spacing: 0.12em;
color: #f2f7fc;
}
.brand-sub {
margin-top: 5px;
font-size: 11px;
margin-top: 6px;
font-size: clamp(12px, 0.94vw, 16px);
letter-spacing: 0.16em;
color: rgba(220, 231, 243, 0.5);
}
......@@ -239,7 +244,7 @@ onBeforeUnmount(() => {
.mast-nav {
display: flex;
align-items: stretch;
gap: 28px;
gap: clamp(20px, 1.8vw, 32px);
align-self: stretch;
}
......@@ -247,7 +252,7 @@ onBeforeUnmount(() => {
position: relative;
display: flex;
align-items: center;
font-size: 14px;
font-size: clamp(13px, 0.95vw, 16px);
letter-spacing: 0.04em;
color: rgba(220, 231, 243, 0.68);
text-decoration: none;
......@@ -306,10 +311,10 @@ onBeforeUnmount(() => {
.avatar {
display: grid;
place-items: center;
width: 32px;
height: 32px;
width: clamp(30px, 2.2vw, 40px);
height: clamp(30px, 2.2vw, 40px);
font-family: var(--display);
font-size: 14px;
font-size: clamp(13px, 0.95vw, 16px);
color: #dce7f3;
background: var(--ink-3);
border: 1px solid rgba(201, 162, 75, 0.6);
......@@ -324,12 +329,12 @@ onBeforeUnmount(() => {
}
.user-name {
font-size: 13px;
font-size: clamp(13px, 0.9vw, 15px);
color: #f2f7fc;
}
.user-role {
font-size: 10px;
font-size: clamp(10px, 0.72vw, 12px);
letter-spacing: 0.08em;
color: rgba(220, 231, 243, 0.46);
}
......@@ -348,7 +353,7 @@ onBeforeUnmount(() => {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 224px;
width: clamp(216px, 16vw, 260px);
padding: 6px;
background: var(--paper);
border: 1px solid var(--line);
......@@ -423,7 +428,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
gap: clamp(3px, 0.3vw, 5px);
}
.portal-foot-copy span + span {
......@@ -437,7 +442,7 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: center;
gap: 16px;
padding: 22px 24px;
padding: clamp(18px, 1.6vw, 28px) clamp(20px, 2vw, 32px);
font-size: 13px;
letter-spacing: 0.06em;
color: var(--muted);
......@@ -502,13 +507,133 @@ onBeforeUnmount(() => {
.portal.is-dark-portal .portal-foot {
position: relative;
z-index: 0;
font-size: 15px;
font-size: clamp(16px, 1.18vw, 20px);
letter-spacing: 0.08em;
color: rgba(220, 231, 243, 0.58);
color: rgba(220, 231, 243, 0.76);
background: transparent;
border-top: 0;
}
.portal.is-dark-portal .portal-foot-copy span + span {
font-size: clamp(13px, 0.98vw, 17px);
color: rgba(220, 231, 243, 0.56);
opacity: 1;
}
.portal.is-light-app-center .masthead {
color: #1f2937;
background: #ffffff;
border-top: 0;
border-bottom: 1px solid #e3e8ef;
box-shadow: 0 1px 0 rgba(16, 24, 40, 0.02);
backdrop-filter: none;
}
.portal.is-light-app-center .brand-name {
color: #1f2937;
}
.portal.is-light-app-center .brand-sub {
color: #7c8aa0;
}
.portal.is-light-app-center .brand-divider {
background: #e3e8ef;
}
.portal.is-light-app-center .mast-link {
color: #526079;
}
.portal.is-light-app-center .mast-link:hover,
.portal.is-light-app-center .mast-link.is-active {
color: #1677ff;
}
.portal.is-light-app-center .mast-link::after {
background: #1677ff;
}
.portal.is-light-app-center .user-trigger {
color: #3b4658;
}
.portal.is-light-app-center .user-trigger:hover,
.portal.is-light-app-center .user-trigger[aria-expanded="true"] {
background: #f3f5f9;
border-color: #e3e8ef;
}
.portal.is-light-app-center .avatar {
color: #1677ff;
background: #eaf2ff;
border-color: #bcd7ff;
}
.portal.is-light-app-center .user-name {
color: #1f2937;
}
.portal.is-light-app-center .user-role {
color: #8494ab;
}
.portal.is-light-app-center .chevron {
color: #8494ab;
}
.portal.is-light-app-center .menu-panel {
background: #ffffff;
border-color: #e3e8ef;
border-top-color: #1677ff;
box-shadow: 0 18px 40px rgba(16, 24, 40, 0.14);
backdrop-filter: none;
}
.portal.is-light-app-center .menu-head {
border-bottom-color: #eef1f6;
}
.portal.is-light-app-center .menu-name {
color: #1f2937;
}
.portal.is-light-app-center .menu-account {
color: #8494ab;
}
.portal.is-light-app-center .menu-item {
color: #3b4658;
}
.portal.is-light-app-center .menu-item:hover {
background: #eaf2ff;
color: #1677ff;
}
.portal.is-light-app-center .menu-item.is-danger {
color: #dc2626;
}
.portal.is-light-app-center .menu-item.is-danger:hover {
background: #fef2f2;
}
.portal.is-light-app-center .portal-foot {
position: relative;
z-index: 0;
font-size: clamp(16px, 1.18vw, 20px);
letter-spacing: 0.08em;
color: #7c8aa0;
background: #ffffff;
border-top: 0;
}
.portal.is-light-app-center .portal-foot-copy span + span {
color: #8494ab;
opacity: 1;
}
@media (max-height: 820px) {
.portal.is-dark-portal .portal-foot {
padding: 16px 24px;
......
......@@ -17,6 +17,16 @@
--sans: "Noto Sans SC", "Source Han Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
--mono: "JetBrains Mono", "Cascadia Code", Consolas, "Courier New", monospace;
--content-max: clamp(1120px, 78vw, 1680px);
--admin-max: clamp(1280px, 66vw, 1800px);
--page-pad-x: clamp(28px, 2.8vw, 56px);
--gap-lg: clamp(18px, 1.5vw, 30px);
--gap-md: clamp(12px, 1vw, 20px);
--display-xl: clamp(34px, 2.6vw, 46px);
--display-lg: clamp(30px, 2.2vw, 40px);
--text-md: clamp(13px, 0.95vw, 16px);
--text-sm: clamp(12px, 0.85vw, 14px);
--el-color-primary: #11539b;
--el-color-primary-light-3: #4a7cb8;
--el-color-primary-light-5: #7fa3cc;
......@@ -123,7 +133,7 @@ input {
.el-dialog__title {
font-family: var(--display);
font-size: 19px;
font-size: clamp(18px, 1.3vw, 22px);
letter-spacing: 0.03em;
color: var(--ink);
}
......@@ -132,7 +142,7 @@ input {
--el-table-border-color: var(--line);
--el-table-header-bg-color: #f2f6fb;
--el-table-row-hover-bg-color: #f7fafd;
font-size: 13px;
font-size: clamp(12px, 0.85vw, 14px);
}
.el-table th.el-table__cell {
......
<template>
<div class="launcher">
<header class="launcher-head" :class="{ 'is-editing': manageMode }">
<div class="head-copy">
<h1>应用中心</h1>
<p v-if="manageMode" class="edit-hint">按住任意图标拖动调整顺序,点击“完成”退出管理</p>
</div>
<header class="launcher-head">
<div class="launcher-tools">
<el-input
v-if="!manageMode"
v-model="keyword"
class="launcher-search"
placeholder="搜索应用"
clearable
:prefix-icon="Search"
/>
<template v-else>
<el-button :icon="Plus" @click="openCreate">新增应用</el-button>
<el-button type="primary" :loading="savingOrder" @click="exitManage">完成</el-button>
</template>
<el-button v-if="!manageMode" :icon="Shop" @click="marketVisible = true">应用市场</el-button>
<el-button
v-if="isAdmin && !manageMode"
:icon="Setting"
title="管理应用:新增、编辑、删除"
@click="enterManage"
>
管理
</el-button>
<el-button type="primary" :icon="Shop" @click="marketVisible = true">智能体广场</el-button>
</div>
</header>
......@@ -35,18 +17,18 @@
<div v-else-if="apps.length === 0" class="empty">
<p class="empty-title">我的门户还没有应用</p>
<p class="empty-desc">去应用市场挑选需要的应用,添加到自己的门户。</p>
<p class="empty-desc">去智能体广场挑选需要的应用,添加到自己的门户。</p>
<el-button type="primary" :icon="Shop" class="empty-action" @click="marketVisible = true">
去应用市场
去智能体广场
</el-button>
</div>
<div v-else-if="!manageMode && keyword.trim() && filtered.length === 0" class="empty">
<div v-else-if="keyword.trim() && filtered.length === 0" class="empty">
<p class="empty-title">没有匹配的应用</p>
<p class="empty-desc">换一个关键词试试。</p>
</div>
<div v-else-if="!manageMode && keyword.trim()" class="app-grid">
<div v-else-if="keyword.trim()" class="app-grid">
<AppTile
v-for="app in filtered"
:key="app.id"
......@@ -64,7 +46,7 @@
:class="{ 'is-dragging': dragging }"
ghost-class="app-ghost"
draggable=".slot-cell"
filter=".slot-cell.is-empty, .app-actions"
filter=".slot-cell.is-empty"
:prevent-on-filter="false"
:animation="180"
:disabled="savingOrder"
......@@ -81,10 +63,6 @@
<AppTile
v-if="element.app"
:app="element.app"
:manage="manageMode"
:linkable="!manageMode"
@edit="editSlot(element)"
@remove="removeSlot(element)"
@menu="openContextMenu"
/>
<span v-else class="empty-slot" aria-hidden="true"></span>
......@@ -92,13 +70,6 @@
</template>
</draggable>
<AppEditorDialog
v-model="editorVisible"
:app="editingApp"
:next-sort-order="nextSortOrder"
@saved="loadApps"
/>
<AppMarketDialog v-model="marketVisible" @changed="loadApps" />
<Teleport to="body">
......@@ -121,15 +92,12 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Setting, Shop, Delete } from '@element-plus/icons-vue'
import { Search, Shop, Delete } from '@element-plus/icons-vue'
import draggable from 'vuedraggable'
import AppTile from '@/components/AppTile.vue'
import AppEditorDialog from '@/components/AppEditorDialog.vue'
import AppMarketDialog from '@/components/AppMarketDialog.vue'
import {
adminApi,
portalApi,
type AdminApp,
type PortalApp,
} from '@/api/portal'
import { apiMessage } from '@/api/http'
......@@ -147,20 +115,16 @@ interface ContextMenuState {
app: PortalApp | null
}
const GRID_COLUMNS = 6
const GRID_COLUMNS = 8
const GRID_MIN_ROWS = 3
const GRID_MAX_SLOTS = 200
const auth = useAuthStore()
const apps = ref<PortalApp[]>([])
const gridSlots = ref<GridSlot[]>([])
const adminApps = ref<AdminApp[]>([])
const loading = ref(true)
const keyword = ref('')
const manageMode = ref(false)
const editorVisible = ref(false)
const marketVisible = ref(false)
const editingApp = ref<AdminApp | null>(null)
const savingOrder = ref(false)
const dragging = ref(false)
const justDragged = ref(false)
......@@ -174,12 +138,6 @@ let suppressClickTimer: number | undefined
let dragStartIndex: number | null = null
let dragSnapshot: GridSlot[] = []
const isAdmin = computed(() => auth.user?.role === 'ADMIN')
const nextSortOrder = computed(
() => adminApps.value.reduce((max, app) => Math.max(max, app.sortOrder), 0) + 10,
)
const filtered = computed(() => {
const term = keyword.value.trim().toLowerCase()
if (!term) return apps.value
......@@ -194,12 +152,6 @@ async function loadApps() {
const { data } = await portalApi.apps()
apps.value = data
gridSlots.value = buildSlots(data)
if (isAdmin.value) {
const { data: full } = await adminApi.listApps()
adminApps.value = full
} else {
adminApps.value = []
}
} catch (error) {
ElMessage.error(apiMessage(error))
} finally {
......@@ -207,23 +159,6 @@ async function loadApps() {
}
}
async function enterManage() {
keyword.value = ''
editingApp.value = null
await loadApps()
manageMode.value = true
}
async function exitManage() {
manageMode.value = false
await loadApps()
}
function openCreate() {
editingApp.value = null
editorVisible.value = true
}
function buildSlots(items: PortalApp[]): GridSlot[] {
const byPosition = new Map<number, PortalApp>()
const used = new Set<number>()
......@@ -270,18 +205,6 @@ function buildSlots(items: PortalApp[]): GridSlot[] {
}))
}
function editSlot(slot: GridSlot) {
if (slot.app) {
openEdit(slot.app)
}
}
function removeSlot(slot: GridSlot) {
if (slot.app) {
void removeFromPortal(slot.app)
}
}
function onDragStart(event: { oldIndex?: number }) {
dragStartIndex = typeof event.oldIndex === 'number' ? event.oldIndex : null
dragSnapshot = gridSlots.value.map((slot) => ({ key: slot.key, app: slot.app }))
......@@ -294,7 +217,7 @@ let pointerStartY = 0
function onGridPointerDown(event: PointerEvent) {
if (event.button !== 0) return
const target = event.target as HTMLElement | null
if (!target || target.closest('.app-actions') || !target.closest('.slot-cell.has-app')) {
if (!target || !target.closest('.slot-cell.has-app')) {
return
}
pointerDown = true
......@@ -350,7 +273,7 @@ async function uninstallApp() {
async function removeFromPortal(app: PortalApp) {
try {
await ElMessageBox.confirm(
`从我的门户移除「${app.appName}」?只隐藏你自己的显示,不会下架应用,也不影响其他用户;之后可以在应用市场重新添加。`,
`从我的门户移除「${app.appName}」?只隐藏你自己的显示,不会下架应用,也不影响其他用户;之后可以在智能体广场重新添加。`,
'移除应用',
{
type: 'warning',
......@@ -364,20 +287,13 @@ async function removeFromPortal(app: PortalApp) {
}
try {
await portalApi.hideApp(app.id)
ElMessage.success('已从我的门户移除,可在应用市场重新添加')
ElMessage.success('已从我的门户移除,可在智能体广场重新添加')
await loadApps()
} catch (error) {
ElMessage.error(apiMessage(error))
}
}
function openEdit(app: PortalApp) {
const full = adminApps.value.find((item) => item.id === app.id)
if (!full) return
editingApp.value = full
editorVisible.value = true
}
async function persistLayout(event: { newIndex?: number }) {
dragging.value = false
......@@ -471,134 +387,117 @@ onBeforeUnmount(() => {
.launcher {
position: relative;
z-index: 1;
max-width: 1120px;
margin: 0 auto;
padding: 64px 40px 88px;
max-width: var(--content-max);
margin: clamp(24px, 3.2vh, 42px) auto;
padding: clamp(12px, 1.28vw, 20px) var(--page-pad-x) clamp(36px, 4vw, 56px);
background: #ffffff;
}
.launcher-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding-bottom: 22px;
border-bottom: 1px solid rgba(169, 201, 232, 0.16);
}
.launcher-head.is-editing {
align-items: flex-start;
}
.launcher-head h1 {
margin: 0;
font-family: var(--display);
font-size: 32px;
font-weight: 600;
letter-spacing: 0.06em;
color: #f2f7fc;
}
.edit-hint {
margin: 9px 0 0;
font-size: 12px;
color: rgba(220, 231, 243, 0.5);
justify-content: flex-end;
gap: var(--gap-lg);
padding-bottom: clamp(12px, 1.28vw, 20px);
border-bottom: 1px solid #e3e8ef;
}
.launcher-tools {
display: flex;
align-items: center;
gap: 10px;
gap: clamp(9px, 0.8vw, 14px);
flex-wrap: wrap;
}
.launcher-search {
width: 280px;
width: clamp(260px, 20vw, 380px);
}
.launcher-search :deep(.el-input__wrapper) {
height: 38px;
height: clamp(29px, 2.08vw, 37px);
padding: 1px 14px;
background: rgba(8, 32, 60, 0.66);
border-radius: 4px;
box-shadow: 0 0 0 1px rgba(169, 201, 232, 0.22) inset;
background: #ffffff;
border-radius: 6px;
box-shadow: 0 0 0 1px #e3e8ef inset;
transition: background 0.16s ease, box-shadow 0.16s ease;
}
.launcher-search :deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px rgba(169, 201, 232, 0.36) inset;
box-shadow: 0 0 0 1px #cbd5e1 inset;
}
.launcher-search :deep(.el-input__wrapper.is-focus) {
background: rgba(8, 32, 60, 0.82);
background: #ffffff;
box-shadow:
0 0 0 1px rgba(169, 201, 232, 0.58) inset,
0 0 0 3px rgba(17, 83, 155, 0.18);
0 0 0 1px #1677ff inset,
0 0 0 3px rgba(22, 119, 255, 0.12);
}
.launcher-search :deep(.el-input__inner) {
color: #edf4fb;
color: #1f2937;
}
.launcher-search :deep(.el-input__inner::placeholder) {
color: rgba(220, 231, 243, 0.4);
color: #8494ab;
}
.launcher-search :deep(.el-input__prefix-inner),
.launcher-search :deep(.el-input__suffix-inner) {
color: rgba(220, 231, 243, 0.5);
color: #71809b;
}
.launcher-tools :deep(.el-button) {
height: 38px;
padding: 0 14px;
color: #dce7f3;
background: rgba(8, 32, 60, 0.56);
border-color: rgba(169, 201, 232, 0.22);
border-radius: 4px;
height: clamp(29px, 2.08vw, 37px);
padding: 0 clamp(12px, 1.1vw, 18px);
color: #3b4658;
background: #ffffff;
border-color: #e3e8ef;
border-radius: 6px;
transition: color 0.16s ease, background 0.16s ease, border-color 0.16s ease;
}
.launcher-tools :deep(.el-button:hover),
.launcher-tools :deep(.el-button:focus-visible) {
color: #f2f7fc;
background: rgba(17, 83, 155, 0.52);
border-color: rgba(169, 201, 232, 0.5);
color: #1677ff;
background: #eaf2ff;
border-color: #bcd7ff;
}
.launcher-tools :deep(.el-button--primary) {
color: #fff;
background: #11539b;
border-color: #2d6fab;
color: #ffffff;
background: #1677ff;
border-color: #1677ff;
}
.launcher-tools :deep(.el-button--primary:hover) {
background: #0b3f78;
border-color: #3d7fba;
color: #ffffff;
background: #0b5ed7;
border-color: #0b5ed7;
}
.launcher :focus-visible {
outline-color: rgba(169, 201, 232, 0.78);
outline-color: #1677ff;
}
.app-grid {
display: flex;
flex-wrap: wrap;
gap: 22px 8px;
margin-top: 34px;
gap: clamp(10px, 0.9vw, 18px) clamp(4px, 0.4vw, 6px);
margin-top: clamp(18px, 1.8vw, 32px);
}
.slot-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 18px 8px;
margin-top: 30px;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: clamp(8px, 0.8vw, 16px) clamp(4px, 0.4vw, 6px);
margin-top: clamp(18px, 1.8vw, 32px);
}
.slot-cell {
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 138px;
min-height: clamp(108px, 7.6vw, 148px);
border-radius: 16px;
transition: background 0.16s ease;
}
......@@ -608,20 +507,40 @@ onBeforeUnmount(() => {
}
.empty-slot {
width: 84px;
height: 84px;
width: clamp(62px, 4.64vw, 86px);
height: clamp(62px, 4.64vw, 86px);
margin-top: 10px;
border: 1px dashed rgba(169, 201, 232, 0.18);
border-radius: 20px;
background: rgba(169, 201, 232, 0.025);
border: 1px dashed #cfdae8;
border-radius: 16px;
background: rgba(22, 119, 255, 0.03);
opacity: 0;
transition: opacity 0.16s ease, border-color 0.16s ease, background 0.16s ease;
}
.slot-grid.is-dragging .empty-slot {
opacity: 1;
border-color: rgba(169, 201, 232, 0.56);
background: rgba(17, 83, 155, 0.14);
border-color: #1677ff;
background: rgba(22, 119, 255, 0.08);
}
.launcher :deep(.app-name) {
color: #3b4658;
}
.launcher :deep(.app-item:hover .app-name) {
color: #1677ff;
}
.launcher :deep(.app-icon) {
box-shadow:
0 10px 24px rgba(16, 24, 40, 0.16),
0 0 0 1px rgba(16, 24, 40, 0.06);
}
.launcher :deep(.app-item:hover .app-icon) {
box-shadow:
0 14px 30px rgba(16, 24, 40, 0.2),
0 0 0 1px rgba(22, 119, 255, 0.22);
}
.app-context-menu {
......@@ -629,10 +548,10 @@ onBeforeUnmount(() => {
z-index: 3000;
min-width: 180px;
padding: 6px;
background: rgba(8, 32, 60, 0.97);
border: 1px solid rgba(169, 201, 232, 0.24);
background: rgba(255, 255, 255, 0.98);
border: 1px solid #e3e8ef;
border-radius: 8px;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.42);
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.16);
backdrop-filter: blur(14px);
}
......@@ -643,7 +562,7 @@ onBeforeUnmount(() => {
width: 100%;
padding: 9px 12px;
font-size: 13px;
color: #f0a4aa;
color: #b3262e;
text-align: left;
background: transparent;
border: none;
......@@ -652,7 +571,7 @@ onBeforeUnmount(() => {
}
.context-menu-item:hover {
background: rgba(179, 38, 46, 0.18);
background: #fef2f2;
}
.context-menu-item .el-icon {
......@@ -662,17 +581,16 @@ onBeforeUnmount(() => {
.state-line {
padding: 56px 0;
font-size: 13px;
color: rgba(220, 231, 243, 0.5);
color: #71809b;
}
.empty {
margin-top: 34px;
padding: 64px 24px;
text-align: center;
background: rgba(8, 32, 60, 0.38);
border: 1px dashed rgba(169, 201, 232, 0.22);
border-radius: 8px;
backdrop-filter: blur(6px);
background: #ffffff;
border: 1px dashed #cfdae8;
border-radius: 10px;
}
.empty-title {
......@@ -681,13 +599,13 @@ onBeforeUnmount(() => {
font-size: 22px;
font-weight: 600;
letter-spacing: 0.03em;
color: #f2f7fc;
color: #1f2937;
}
.empty-desc {
margin: 10px 0 0;
font-size: 13px;
color: rgba(220, 231, 243, 0.55);
color: #71809b;
}
.empty-action {
......@@ -695,13 +613,13 @@ onBeforeUnmount(() => {
}
.empty :deep(.el-button--primary) {
background: #11539b;
border-color: #2d6fab;
background: #1677ff;
border-color: #1677ff;
}
.empty :deep(.el-button--primary:hover) {
background: #0b3f78;
border-color: #3d7fba;
background: #0b5ed7;
border-color: #0b5ed7;
}
@media (max-width: 900px) {
......@@ -756,16 +674,12 @@ onBeforeUnmount(() => {
@media (max-height: 820px) {
.launcher {
padding-top: 30px;
padding-top: 12px;
padding-bottom: 20px;
}
.launcher-head {
padding-bottom: 16px;
}
.launcher-head h1 {
font-size: 28px;
padding-bottom: 12px;
}
.slot-grid,
......@@ -781,18 +695,16 @@ onBeforeUnmount(() => {
@media (max-height: 760px) {
.launcher {
padding-top: 20px;
padding-top: 9px;
padding-bottom: 14px;
}
.slot-cell {
min-height: 96px;
.launcher-head {
padding-bottom: 9px;
}
}
@media (max-width: 520px) {
.launcher-head h1 {
font-size: 26px;
.slot-cell {
min-height: 96px;
}
}
</style>
......@@ -75,11 +75,13 @@
<div class="record-head">
<span>登录时间</span>
<span>来源 IP</span>
<span>方式</span>
<span>结果</span>
</div>
<div v-for="record in loginRecords" :key="record.id" class="record-row">
<span class="record-time">{{ record.createdAt }}</span>
<span class="record-ip" :title="record.clientIp || ''">{{ record.clientIp || '—' }}</span>
<span class="record-type">{{ loginTypeLabel(record.loginType) }}</span>
<span class="record-status">成功</span>
</div>
</div>
......@@ -118,6 +120,12 @@ async function loadLoginRecords() {
}
}
function loginTypeLabel(code: string | null) {
if (code === 'PASSWORD') return '账号密码'
if (code === 'CERTIFICATE') return '数字证书'
return '—'
}
function startEditEmail() {
emailInput.value = auth.user?.email ?? ''
emailError.value = ''
......@@ -161,13 +169,13 @@ onMounted(async () => {
.profile {
position: relative;
z-index: 1;
max-width: 1120px;
max-width: var(--content-max);
margin: 0 auto;
padding: 46px 40px 74px;
padding: clamp(36px, 3.4vw, 64px) var(--page-pad-x) clamp(56px, 4.5vw, 88px);
}
.page-head {
padding-bottom: 26px;
padding-bottom: clamp(20px, 1.8vw, 30px);
border-bottom: 1px solid rgba(169, 201, 232, 0.16);
}
......@@ -180,9 +188,9 @@ onMounted(async () => {
}
h1 {
margin: 16px 0 12px;
margin: clamp(12px, 1.2vw, 18px) 0 clamp(10px, 1vw, 14px);
font-family: var(--display);
font-size: 36px;
font-size: var(--display-xl);
font-weight: 600;
letter-spacing: 0.05em;
color: #f2f7fc;
......@@ -191,7 +199,7 @@ h1 {
.lede {
max-width: 44em;
margin: 0;
font-size: 14px;
font-size: var(--text-md);
line-height: 1.9;
color: rgba(220, 231, 243, 0.56);
}
......@@ -199,12 +207,12 @@ h1 {
.profile-grid {
display: grid;
grid-template-columns: 1.15fr 1fr;
gap: 22px;
margin-top: 28px;
gap: var(--gap-lg);
margin-top: clamp(22px, 2vw, 34px);
}
.profile-card {
padding: 26px 28px;
padding: clamp(22px, 1.8vw, 36px);
background: linear-gradient(165deg, rgba(16, 54, 92, 0.52) 0%, rgba(8, 32, 60, 0.64) 100%);
border: 1px solid rgba(169, 201, 232, 0.16);
border-top: 2px solid rgba(169, 201, 232, 0.5);
......@@ -214,9 +222,9 @@ h1 {
}
.profile-card h2 {
margin: 0 0 18px;
margin: 0 0 clamp(16px, 1.3vw, 22px);
font-family: var(--display);
font-size: 21px;
font-size: clamp(20px, 1.5vw, 26px);
font-weight: 600;
letter-spacing: 0.03em;
color: #f2f7fc;
......@@ -230,10 +238,10 @@ h1 {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 24px;
padding: 13px 0;
gap: var(--gap-lg);
padding: clamp(11px, 0.9vw, 16px) 0;
border-bottom: 1px dashed rgba(169, 201, 232, 0.14);
font-size: 13px;
font-size: var(--text-md);
}
.info-row:last-child {
......@@ -336,9 +344,9 @@ h1 {
.record-head,
.record-row {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr) 44px;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.9fr) 64px 40px;
align-items: center;
gap: 12px;
gap: 8px;
}
.record-head {
......@@ -377,6 +385,12 @@ h1 {
white-space: nowrap;
}
.record-type {
font-size: 11px;
color: rgba(220, 231, 243, 0.54);
white-space: nowrap;
}
.record-status {
justify-self: end;
padding: 2px 7px;
......
......@@ -28,7 +28,7 @@
class="nav-item"
:class="{ 'is-active': route.path.startsWith('/admin/apps') }"
>
市场管理
智能体广场管理
</RouterLink>
<RouterLink
to="/admin/users"
......@@ -67,24 +67,24 @@ onMounted(statsStore.refresh)
<style scoped>
.admin {
max-width: 1280px;
max-width: var(--admin-max);
margin: 0 auto;
padding: 42px 40px 74px;
padding: clamp(32px, 3vw, 56px) var(--page-pad-x) clamp(56px, 4.5vw, 88px);
}
.admin-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 32px;
padding-bottom: 26px;
gap: var(--gap-lg);
padding-bottom: clamp(20px, 1.8vw, 30px);
border-bottom: 1px solid var(--line);
}
h1 {
margin: 14px 0 0;
margin: clamp(12px, 1.1vw, 18px) 0 0;
font-family: var(--display);
font-size: 38px;
font-size: clamp(34px, 2.8vw, 46px);
font-weight: 600;
letter-spacing: 0.03em;
color: var(--ink);
......@@ -100,7 +100,7 @@ h1 {
display: flex;
flex-direction: column;
align-items: flex-end;
padding: 0 24px;
padding: 0 clamp(18px, 1.6vw, 30px);
border-left: 1px solid var(--line);
}
......@@ -110,7 +110,7 @@ h1 {
.stat strong {
font-family: var(--display);
font-size: 28px;
font-size: clamp(26px, 2vw, 36px);
font-weight: 600;
line-height: 1;
color: var(--ink);
......@@ -118,16 +118,16 @@ h1 {
.stat span {
margin-top: 6px;
font-size: 11px;
font-size: clamp(11px, 0.8vw, 13px);
letter-spacing: 0.05em;
color: var(--muted);
}
.admin-body {
display: grid;
grid-template-columns: 168px minmax(0, 1fr);
gap: 34px;
margin-top: 30px;
grid-template-columns: clamp(160px, 9vw, 220px) minmax(0, 1fr);
gap: var(--gap-lg);
margin-top: clamp(22px, 2.2vw, 36px);
}
.admin-nav {
......@@ -136,13 +136,13 @@ h1 {
gap: 2px;
align-self: start;
position: sticky;
top: 104px;
top: calc(clamp(68px, 4.6vw, 92px) + 24px);
padding-top: 4px;
}
.nav-item {
padding: 11px 14px;
font-size: 13px;
padding: clamp(10px, 0.85vw, 14px) clamp(12px, 1vw, 18px);
font-size: clamp(13px, 0.95vw, 15px);
color: var(--muted);
text-decoration: none;
border-left: 2px solid transparent;
......
......@@ -2,8 +2,8 @@
<div class="view">
<header class="view-head">
<div>
<h2>应用市场</h2>
<p>统一维护市场中的应用与统一认证接入;普通用户可自行添加或移除。</p>
<h2>智能体广场</h2>
<p>统一维护智能体广场中的应用与统一认证接入;普通用户可自行添加或移除。</p>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">新增应用</el-button>
</header>
......@@ -149,7 +149,7 @@
active-text="上架"
inactive-text="下架"
/>
<span class="form-tip">下架后应用不再显示在门户和应用市场,入口暂停访问;已建立的会话不会立即失效。</span>
<span class="form-tip">下架后应用不再显示在门户和智能体广场,入口暂停访问;已建立的会话不会立即失效。</span>
</div>
</el-form-item>
</el-form>
......@@ -598,8 +598,8 @@ async function toggleListing(row: AdminApp) {
try {
await ElMessageBox.confirm(
nextStatus === 0
? `下架「${row.appName}」后,普通用户将无法在应用市场和门户看到该应用,入口也会暂停访问;已打开的子会话不受影响。`
: `重新上架「${row.appName}」?普通用户可以在应用市场中看到并添加该应用。`,
? `下架「${row.appName}」后,普通用户将无法在智能体广场和门户看到该应用,入口也会暂停访问;已打开的子会话不受影响。`
: `重新上架「${row.appName}」?普通用户可以在智能体广场中看到并添加该应用。`,
`${actionLabel}应用`,
{
type: 'warning',
......@@ -775,7 +775,7 @@ onMounted(loadApps)
.view-head h2 {
margin: 0;
font-family: var(--display);
font-size: 25px;
font-size: clamp(23px, 1.8vw, 30px);
font-weight: 600;
letter-spacing: 0.03em;
color: var(--ink);
......
......@@ -57,36 +57,41 @@
<span class="muted-cell">{{ row.createdAt }}</span>
</template>
</el-table-column>
<el-table-column label="事件" width="112">
<el-table-column label="事件" width="100">
<template #default="{ row }">
<el-tag :type="eventTagType(row.eventType)" effect="plain" size="small">
{{ eventLabel(row.eventType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="用户" width="112">
<el-table-column label="方式" width="86">
<template #default="{ row }">
<span class="muted-cell">{{ loginTypeLabel(row.loginType) }}</span>
</template>
</el-table-column>
<el-table-column label="用户" width="104">
<template #default="{ row }">
<span class="mono-cell">{{ row.username || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="来源 IP" width="150">
<el-table-column label="来源 IP" width="140">
<template #default="{ row }">
<span class="mono-cell">{{ row.clientIp || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="结果" width="76">
<el-table-column label="结果" width="70">
<template #default="{ row }">
<el-tag :type="row.success ? 'success' : 'danger'" effect="plain" size="small">
{{ row.success ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="对象" min-width="110">
<el-table-column label="对象" width="140">
<template #default="{ row }">
<span class="muted-cell">{{ targetLabel(row) }}</span>
</template>
</el-table-column>
<el-table-column label="详情" min-width="180" show-overflow-tooltip>
<el-table-column label="详情" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span class="muted-cell">{{ row.detail || '—' }}</span>
</template>
......@@ -126,6 +131,8 @@
<dd>{{ selected.createdAt || '—' }}</dd>
<dt>事件</dt>
<dd>{{ eventLabel(selected.eventType) }}({{ selected.eventType }})</dd>
<dt>登录方式</dt>
<dd>{{ loginTypeLabel(selected.loginType) }}</dd>
<dt>结果</dt>
<dd>{{ selected.success ? '成功' : '失败' }}</dd>
<dt>用户</dt>
......@@ -160,9 +167,12 @@ const EVENT_LABELS: Record<string, string> = {
LOGIN_SUCCESS: '登录成功',
LOGIN_FAILURE: '登录失败',
LOGIN_BLOCKED: '登录限流',
LOGIN_IP_CHANGED: '登录IP变更',
LOGOUT: '退出登录',
USER_CREATE: '新增用户',
USER_UPDATE: '修改用户',
USER_PASSWORD_CHANGE: '修改密码',
USER_AUTO_DISABLE: '自动停用账号',
USER_PASSWORD_RESET: '重置密码',
USER_DELETE: '删除用户',
USER_IMPORT: '批量导入用户',
......@@ -240,17 +250,27 @@ function eventLabel(code: string) {
return EVENT_LABELS[code] ?? code
}
function loginTypeLabel(code: string | null) {
if (code === 'PASSWORD') return '账号密码'
if (code === 'CERTIFICATE') return '数字证书'
return '—'
}
function eventTagType(code: string): 'success' | 'warning' | 'danger' | 'info' {
if (code === 'LOGIN_SUCCESS') return 'success'
if (code === 'LOGIN_BLOCKED') return 'warning'
if (code === 'LOGIN_FAILURE' || code.endsWith('_DELETE')) return 'danger'
if (code === 'USER_PASSWORD_RESET' || code === 'APP_SECRET_ROTATE') return 'warning'
if (code === 'LOGIN_FAILURE' || code === 'LOGIN_IP_CHANGED' || code.endsWith('_DELETE')) return 'danger'
if (code === 'USER_PASSWORD_RESET' || code === 'APP_SECRET_ROTATE'
|| code === 'USER_AUTO_DISABLE') return 'warning'
return 'info'
}
function targetLabel(row: AuditLogEntry) {
if (!row.targetType) return '—'
const type = row.targetType === 'USER' ? '用户' : row.targetType === 'APP' ? '应用' : '认证'
if (row.targetType === 'APP') {
return row.targetName ? `应用 · ${row.targetName}` : row.targetId ? `应用 #${row.targetId}` : '应用'
}
const type = row.targetType === 'USER' ? '用户' : '认证'
return row.targetId ? `${type} #${row.targetId}` : type
}
......@@ -270,7 +290,7 @@ onMounted(load)
.view-head h2 {
margin: 0;
font-family: var(--display);
font-size: 25px;
font-size: clamp(23px, 1.8vw, 30px);
font-weight: 600;
letter-spacing: 0.03em;
color: var(--ink);
......
......@@ -20,7 +20,7 @@
/>
<el-button @click="search">搜索</el-button>
<span class="toolbar-spacer" />
<el-button :icon="Upload" @click="openImport">批量导入</el-button>
<el-button :icon="Download" @click="openImport">批量导入</el-button>
<el-button
type="danger"
plain
......@@ -233,7 +233,7 @@
@closed="resetImport"
>
<div class="import-head">
<p>仅支持 .xlsx,单次最多 200 个用户。建议先下载模板,按表头填写。</p>
<p>仅支持 .xlsx,单次最多 5000 个用户。建议先下载模板,按表头填写。</p>
<el-button link type="primary" :icon="Download" :loading="templateLoading" @click="downloadTemplate">
下载模板
</el-button>
......@@ -253,7 +253,7 @@
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div class="el-upload__text">拖拽文件到此处,或 <em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">仅支持 .xlsx,文件不超过 5MB;密码和手机号建议设置为文本格式。</div>
<div class="el-upload__tip">仅支持 .xlsx,文件不超过 5MB;身份证号和手机号为必填项,建议设置为文本格式。</div>
</template>
</el-upload>
......@@ -290,7 +290,7 @@
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules, UploadFile, UploadInstance } from 'element-plus'
import { Delete, Download, Plus, Search, Upload, UploadFilled } from '@element-plus/icons-vue'
import { Delete, Download, Plus, Search, UploadFilled } from '@element-plus/icons-vue'
import { adminApi, type AdminUser, type ImportUserResult } from '@/api/portal'
import { apiMessage } from '@/api/http'
import { useAuthStore } from '@/stores/auth'
......@@ -771,7 +771,7 @@ onMounted(load)
.view-head h2 {
margin: 0;
font-family: var(--display);
font-size: 25px;
font-size: clamp(23px, 1.8vw, 30px);
font-weight: 600;
letter-spacing: 0.03em;
color: var(--ink);
......
......@@ -13,15 +13,17 @@ export default defineConfig(({ command }) => ({
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8080' },
'/login': { target: 'http://localhost:8080' },
'/logout': { target: 'http://localhost:8080' },
'/oauth2': { target: 'http://localhost:8080' },
'/connect': { target: 'http://localhost:8080' },
'/.well-known': { target: 'http://localhost:8080' },
'/userinfo': { target: 'http://localhost:8080' },
'/css': { target: 'http://localhost:8080' },
'/img': { target: 'http://localhost:8080' },
'/api': { target: 'http://localhost:8092' },
'/login': { target: 'http://localhost:8092' },
'/change-password': { target: 'http://localhost:8092' },
'/logout': { target: 'http://localhost:8092' },
'/oauth2': { target: 'http://localhost:8092' },
'/connect': { target: 'http://localhost:8092' },
'/.well-known': { target: 'http://localhost:8092' },
'/userinfo': { target: 'http://localhost:8092' },
'/css': { target: 'http://localhost:8092' },
'/js': { target: 'http://localhost:8092' },
'/img': { target: 'http://localhost:8092' },
},
},
build: {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment