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 @@ ...@@ -3,7 +3,7 @@
"configurations": [ "configurations": [
{ {
"name": "zhihui-web", "name": "zhihui-web",
"runtimeExecutable": "C:/nvm4w/nodejs/node.exe", "runtimeExecutable": "E:/install/npm/node.exe",
"runtimeArgs": [ "runtimeArgs": [
"zhihui-web/node_modules/vite/bin/vite.js", "zhihui-web/node_modules/vite/bin/vite.js",
"zhihui-web", "zhihui-web",
......
...@@ -8,16 +8,19 @@ ...@@ -8,16 +8,19 @@
- 门户账号登录:本地用户库、BCrypt 密码、管理员/普通用户两种角色;除 `admin` / `demo` 外的账号支持警号、身份证号、手机号三种登录标识,`admin` / `demo` 使用用户名登录 - 门户账号登录:本地用户库、BCrypt 密码、管理员/普通用户两种角色;除 `admin` / `demo` 外的账号支持警号、身份证号、手机号三种登录标识,`admin` / `demo` 使用用户名登录
- 标准 OIDC / OAuth2 认证中心:授权码 + PKCE、JWT、JWKS、UserInfo、RP-Initiated Logout - 标准 OIDC / OAuth2 认证中心:授权码 + PKCE、JWT、JWKS、UserInfo、RP-Initiated Logout
- 应用市场:应用由管理员统一上架,用户从市场挑选应用添加到自己的门户,图标式展示,点击新标签页直达 - 智能体广场:应用由管理员统一上架,用户从智能体广场挑选应用添加到自己的门户,图标式展示,点击新标签页直达
- 个人应用与布局:用户可添加/移除自己的应用,并在固定网格中把图标拖到任意格子;只影响本人,不影响子系统和其他用户 - 个人应用与布局:用户可添加/移除自己的应用,并在固定网格中把图标拖到任意格子;只影响本人,不影响子系统和其他用户
- 应用市场管理:管理员可在首页“管理”模式或管理后台新增、编辑、删除应用;client_secret 加密保存,管理员可在“接入信息”中查看和复制,也可编辑 client_id、client_secret、回调地址和登出回调地址,点击保存时弹窗验证当前管理员密码;支持重置与删除 - 智能体广场管理:管理员在管理后台新增、编辑、删除应用;client_secret 加密保存,管理员可在“接入信息”中查看和复制,也可编辑 client_id、client_secret、回调地址和登出回调地址,点击保存时弹窗验证当前管理员密码;支持重置与删除
- 用户管理:新增、编辑、重置密码、启用/停用、删除,含警号、身份证号、手机号登录字段与 Excel 批量导入/删除;警号、身份证号、手机号用于子系统本地用户匹配,普通用户新增/导入时警号必填 - 用户管理:新增、编辑、重置密码、启用/停用、删除,含警号、身份证号、手机号登录字段与 Excel 批量导入/删除;警号、身份证号、手机号用于子系统本地用户匹配,普通用户新增/导入时警号必填
- 个人中心:展示姓名、警号、身份证号、手机号、登录账号、邮箱和角色;邮箱可由本人自助修改,其他字段只读,未填写的标识显示“未填写” - 个人中心:展示姓名、警号、身份证号、手机号、登录账号、邮箱和角色;邮箱可由本人自助修改,其他字段只读,未填写的标识显示“未填写”
- 登录安全:按“登录标识+IP”和“IP”双维度限流,失败计数存 Redis,默认 15 分钟窗口 - 登录安全:按“登录标识+IP”和“IP”双维度限流,失败计数存 Redis,默认 15 分钟窗口;账号密码与数字证书登录都会记录登录方式和来源 IP,登录会话与 IP 绑定,IP 变更时立即撤销该用户全部会话和子系统授权
- 审计日志:记录登录成功/失败/限流/退出、用户与应用管理操作,管理员可在后台筛选查询;失败登录中的身份证号、手机号会脱敏 - 审计日志:记录登录成功/失败/限流/退出、登录方式(账号密码/数字证书)、登录 IP 变更、用户与应用管理操作,管理员可在后台筛选查询;失败登录中的身份证号、手机号会脱敏
- 已有 Java 系统接入包(`zhihui-sso-starter`):自动发现 OIDC 配置、登录跳转、回调校验、会话建立、退出;向业务代码提供含警号、身份证号、手机号的 `SsoUser`。`admin` / `demo` 禁止进入子系统,其他账号由子系统按这三个标识匹配本地用户;用户名、`sub`、`roles` 和显示名不得作为授权依据 - 已有 Java 系统接入包(`zhihui-sso-starter`):自动发现 OIDC 配置、登录跳转、回调校验、会话建立、退出;向业务代码提供含警号、身份证号、手机号的 `SsoUser`。会话默认每 60 秒调用 UserInfo 重校验,Access Token 失效时自动用 Refresh Token 续期,续期失败则结束本地会话。`admin` / `demo` 禁止进入子系统,其他账号由子系统按这三个标识匹配本地用户;用户名、`sub`、`roles` 和显示名不得作为授权依据
- 两个示例子系统(`zhihui-demo-app` 多实例):完整演示免登、退出、普通用户按警号/身份证号/手机号中的任一标识匹配本地用户、admin/demo 无身份 claims 并由本地校验提示未开通、应用市场添加与个人网格布局 - 应用访问控制:发起 OAuth 授权时校验应用已上架且在当前用户的个人门户中;`admin` / `demo` 及未添加该应用的账号直接收到 403,不下发授权码
- 会话存 Redis,授权记录与客户端存 MySQL,签名密钥持久化,重启不掉线 - 会话与授权撤销:用户改角色、停用、删除、重置密码、修改登录标识或退出时,立即清理其 Redis 会话和 OAuth 授权;应用下架或用户移除应用时,同步撤销对应授权
- 两个示例子系统(`zhihui-demo-app` 多实例):完整演示免登、退出、普通用户按警号/身份证号/手机号中的任一标识匹配本地用户、admin/demo 由门户在授权端点直接拒绝、智能体广场添加与个人网格布局
- 会话存 Redis(索引模式),授权记录与客户端存 MySQL,签名密钥持久化,重启不掉线
- 启动日志不打印任何默认密码、客户端密钥或用户口令
## 架构 ## 架构
...@@ -106,12 +109,13 @@ cp .env.example .env ...@@ -106,12 +109,13 @@ cp .env.example .env
| `ZHIHUI_DEMO_LOCAL_POLICE_NUMBER` | `SMK660909` | 示例子系统本地用户的警号 | | `ZHIHUI_DEMO_LOCAL_POLICE_NUMBER` | `SMK660909` | 示例子系统本地用户的警号 |
| `ZHIHUI_DEMO_LOCAL_ID_CARD_NUMBER` | `110101199001011234` | 示例子系统本地用户的身份证号 | | `ZHIHUI_DEMO_LOCAL_ID_CARD_NUMBER` | `110101199001011234` | 示例子系统本地用户的身份证号 |
| `ZHIHUI_DEMO_LOCAL_PHONE_NUMBER` | `13800138000` | 示例子系统本地用户的手机号 | | `ZHIHUI_DEMO_LOCAL_PHONE_NUMBER` | `13800138000` | 示例子系统本地用户的手机号 |
| `ZHIHUI_SSO_SESSION_REVALIDATE_INTERVAL` | `60s` | 子系统 Starter 调用 UserInfo 重校验会话的间隔 |
| `ZHIHUI_COOKIE_SECURE` | `false` | HTTPS 环境改为 `true` | | `ZHIHUI_COOKIE_SECURE` | `false` | HTTPS 环境改为 `true` |
| `ZHIHUI_LOGIN_MAX_FAILURES_PER_USER_IP` | `5` | 同一登录标识+IP 在窗口内的最大失败次数 | | `ZHIHUI_LOGIN_MAX_FAILURES_PER_USER_IP` | `5` | 同一登录标识+IP 在窗口内的最大失败次数 |
| `ZHIHUI_LOGIN_MAX_FAILURES_PER_IP` | `20` | 同一 IP 在窗口内的最大失败次数 | | `ZHIHUI_LOGIN_MAX_FAILURES_PER_IP` | `20` | 同一 IP 在窗口内的最大失败次数 |
| `ZHIHUI_LOGIN_FAILURE_WINDOW` | `15m` | 失败计数/限流窗口 | | `ZHIHUI_LOGIN_FAILURE_WINDOW` | `15m` | 失败计数/限流窗口 |
| `ZHIHUI_LOGIN_RATE_LIMIT_ENABLED` | `true` | 是否启用登录限流 | | `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. 构建 ### 4. 构建
...@@ -153,19 +157,19 @@ bash scripts/run-demo2.sh ...@@ -153,19 +157,19 @@ bash scripts/run-demo2.sh
bash scripts/smoke-test.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` 无论怎么设置都不能进入。 > 示例子系统的默认本地用户标识为:警号 `SMK660909`、身份证号 `110101199001011234`、手机号 `13800138000`。在用户管理中把普通账号的任一标识设置为对应值,即可进入示例子系统;`admin`、`demo` 无论怎么设置都不能进入。
手工验证: 手工验证:
1. 用 `admin` 登录门户,进入“应用中心”,应看到“示例子系统”和“示例子系统二” 1. 用 `admin` 登录门户,进入“应用中心”,应看到“示例子系统”和“示例子系统二”
2. 点击任一应用,新标签页会直接进入对应子系统,不再要求输入密码 2. 用普通账号登录,先在智能体广场把示例应用添加到自己的门户,再点击图标,新标签页会直接进入对应子系统,不再要求输入密码
3. 所有用户都可按住图标拖到任意格子(空格移动、占用格交换);点“应用市场”可把管理员上架的应用添加到自己的门户,鼠标移到图标上点 × 只会从自己的门户移除;管理员点“管理”维护全局应用 3. 所有用户都可按住图标拖到任意格子(空格移动、占用格交换);点“智能体广场”可把管理员上架的应用添加到自己的门户,右键图标或从智能体广场已添加列表移除只会影响自己的门户
4. 在“管理后台 → 用户管理”给普通用户补录警号、身份证号、手机号后,分别用这三种标识登录;`admin` / `demo` 仍使用用户名 4. 在“管理后台 → 用户管理”给普通用户补录警号、身份证号、手机号后,分别用这三种标识登录;`admin` / `demo` 仍使用用户名
5. 用 `admin` 或 `demo` 点击应用,应看到与普通未开通账号一致的“当前账号未开通本系统”;普通账号点击应用可正常免登进入 5. 用 `admin` 或 `demo` 点击应用,会看到门户的“无法访问应用”页面(403),不会跳转到子系统;没有把应用添加到个人门户的普通账号同样会被拒绝
6. 在子系统中点击“退出登录”,会结束统一认证会话并回到登录页 6. 在子系统中点击“退出登录”,会结束统一认证会话并回到登录页
### 7. 前端开发模式 ### 7. 前端开发模式
...@@ -181,25 +185,26 @@ Vite 会把 `/api`、`/login` 等请求代理到 8080。开发模式下登录成 ...@@ -181,25 +185,26 @@ Vite 会把 `/api`、`/login` 等请求代理到 8080。开发模式下登录成
1. 用户在门户登录,认证中心在浏览器写入 HttpOnly 会话 Cookie(存 Redis) 1. 用户在门户登录,认证中心在浏览器写入 HttpOnly 会话 Cookie(存 Redis)
2. 用户打开子系统,子系统 Starter 发现本地没有登录用户,302 到认证中心的 `/oauth2/authorize`,携带 `state`、`nonce` 和 PKCE 2. 用户打开子系统,子系统 Starter 发现本地没有登录用户,302 到认证中心的 `/oauth2/authorize`,携带 `state`、`nonce` 和 PKCE
3. 认证中心发现浏览器已有登录会话,直接返回一次性授权码,不再展示登录页 3. 认证中心发现浏览器已有登录会话,并校验该应用已上架且已添加到当前用户的个人门户,通过后直接返回一次性授权码,不再展示登录页
4. 子系统在**服务端**用授权码 + `client_secret` 换取 ID Token / Access Token 4. 子系统在**服务端**用授权码 + `client_secret` 换取 ID Token / Access Token
5. 子系统校验 JWT 签名(JWKS)、`iss`、`aud`、`exp`、`nonce`,通过后写入自己的本地会话 5. 子系统校验 JWT 签名(JWKS)、`iss`、`aud`、`exp`、`nonce`,通过后写入自己的本地会话
6. 之后用户访问该子系统不再经过认证中心,直到本地会话过期或主动退出 6. 之后用户访问该子系统不再经过认证中心;Starter 默认每 60 秒调用一次 UserInfo 重校验,账号被停用、删除或应用被移除后,最迟一个校验周期内失去访问
client_secret 始终只在服务端使用,浏览器全程接触不到。 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 已把旧排序压缩为格子索引) - 个人数据:格子位置存在 `user_launcher_order.sort_order`(从 0 开始,空格不落库),已添加的应用在 `user_app_selection`(V5 已取代 `user_hidden_app`;V7 已把旧排序压缩为格子索引)
- 新添加但尚未摆放的应用会追加到现有布局之后的空格 - 新添加但尚未摆放的应用会追加到现有布局之后的空格
...@@ -218,16 +223,17 @@ client_secret 始终只在服务端使用,浏览器全程接触不到。 ...@@ -218,16 +223,17 @@ client_secret 始终只在服务端使用,浏览器全程接触不到。
- 身份证号、手机号为可选字段;未填写时只是不能使用该方式登录。普通用户的警号仍然必填。 - 身份证号、手机号为可选字段;未填写时只是不能使用该方式登录。普通用户的警号仍然必填。
- 除 `admin`、`demo` 外,其他账号应至少填写警号、身份证号、手机号中的一项,否则没有可用的登录标识;`ADMIN` 角色账号可留空警号,但同样需要身份证号或手机号才能登录。 - 除 `admin`、`demo` 外,其他账号应至少填写警号、身份证号、手机号中的一项,否则没有可用的登录标识;`ADMIN` 角色账号可留空警号,但同样需要身份证号或手机号才能登录。
- 身份证号、手机号在系统中唯一,避免一个登录标识对应多个用户;导入和后台新增/编辑都会校验。 - 身份证号、手机号在系统中唯一,避免一个登录标识对应多个用户;后台手工新增/编辑时可选,批量导入时两者必填,导入和手工操作都会校验格式与重复。
- 不满足身份证号或手机号格式的字符串一律按警号处理。因此,如果某个警号本身恰好是 18 位身份证格式或 11 位手机号格式,登录时会被当作身份证号/手机号查询,应避免使用这类警号。 - 不满足身份证号或手机号格式的字符串一律按警号处理。因此,如果某个警号本身恰好是 18 位身份证格式或 11 位手机号格式,登录时会被当作身份证号/手机号查询,应避免使用这类警号。
- 子系统的本地用户匹配使用 ID Token / UserInfo 中的 `police_number`、`id_card_number`、`phone_number`:三个标识中任意一个非空且与本地记录一致即可放行;门户登录使用哪种标识不影响这三个 claim。 - 子系统的本地用户匹配使用 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` 2. 填写访问地址;回调地址留空时默认是 `{访问地址}/sso/callback`
3. 保存后可在“接入信息”中随时查看并复制 `client_id` 和 `client_secret`(密钥加密存储,仅管理员可见,查看会写审计日志);点击“编辑”可修改接入标识、密钥和回调地址,点击保存时弹窗验证当前管理员密码 3. 保存后可在“接入信息”中随时查看并复制 `client_id` 和 `client_secret`(密钥加密存储,仅管理员可见,查看会写审计日志);点击“编辑”可修改接入标识、密钥和回调地址,点击保存时弹窗验证当前管理员密码
4. 子系统按 [docs/接入指南.md](docs/接入指南.md) 接入:文档涵盖 PHP、Python、Java(Starter / Spring Security OIDC)和自研客户端,并说明三个身份标识读取与本地用户校验。 4. 子系统按 [docs/接入指南.md](docs/接入指南.md) 接入:文档涵盖 PHP、Python、Java(Starter / Spring Security OIDC)和自研客户端,并说明三个身份标识读取与本地用户校验。
...@@ -235,10 +241,11 @@ client_secret 始终只在服务端使用,浏览器全程接触不到。 ...@@ -235,10 +241,11 @@ client_secret 始终只在服务端使用,浏览器全程接触不到。
### 子系统身份识别规则 ### 子系统身份识别规则
- 门户登录:`admin`、`demo` 使用用户名;其他账号可使用警号、身份证号或手机号(详见“门户登录标识规则”)。 - 门户登录:`admin`、`demo` 使用用户名;其他账号可使用警号、身份证号或手机号(详见“门户登录标识规则”)。
- `admin`、`demo` 禁止进入子系统:门户不签发三个身份 claims,子系统本地校验会显示与普通未开通账号一致的提示;V13 清理升级前的授权记录。 - `admin`、`demo` 禁止进入子系统:门户在授权端点直接返回 403,不会签发授权码或身份 claims;V13 清理升级前的授权记录。
- 其他账号:子系统必须使用 ID Token / UserInfo 中的 `police_number`、`id_card_number`、`phone_number` 匹配本地用户,不能使用 `preferred_username`、`sub`、`roles` 或显示名作为授权键,也没有管理员绕过。 - 其他账号:必须先把应用添加到个人门户;子系统必须使用 ID Token / UserInfo 中的 `police_number`、`id_card_number`、`phone_number` 匹配本地用户,不能使用 `preferred_username`、`sub`、`roles` 或显示名作为授权键,也没有管理员绕过。
- 三个标识中任意一个与本地用户一致且账号启用即可放行;三个标识都为空、账号不存在/停用,或同时命中多个本地用户时拒绝访问。 - 三个标识中任意一个与本地用户一致且账号启用即可放行;三个标识都为空、账号不存在/停用,或同时命中多个本地用户时拒绝访问。
- 警号、身份证号、手机号属于敏感身份信息,已作为 JWT claims 提供给子系统;客户端和子系统不得把 ID Token 暴露给不可信前端或写入日志。 - 警号、身份证号、手机号属于敏感身份信息,已作为 JWT claims 提供给子系统;客户端和子系统不得把 ID Token 暴露给不可信前端或写入日志。
- Starter 默认每 60 秒调用 UserInfo 重校验会话(`session-revalidate-interval`)。门户用户被停用/删除、角色变化或应用被移除后,子系统会在一个校验周期内结束本地会话;Access Token 过期时用 Refresh Token 自动续期,续期失败即退出。
关键约束:认证中心登记的回调地址必须与子系统实际回调**完全一致**(协议、域名、端口、路径都不能差)。 关键约束:认证中心登记的回调地址必须与子系统实际回调**完全一致**(协议、域名、端口、路径都不能差)。
...@@ -247,18 +254,23 @@ client_secret 始终只在服务端使用,浏览器全程接触不到。 ...@@ -247,18 +254,23 @@ client_secret 始终只在服务端使用,浏览器全程接触不到。
- 登录失败按“登录标识+IP”5 次、同一 IP 20 次、15 分钟窗口限流;触发后登录页提示“登录尝试过于频繁”,计数器存在 Redis - 登录失败按“登录标识+IP”5 次、同一 IP 20 次、15 分钟窗口限流;触发后登录页提示“登录尝试过于频繁”,计数器存在 Redis
- 采用登录标识+IP 而不是全局账号锁定,避免攻击者用错误密码把他人账号整体锁死;如仍有分布式暴力破解风险,建议叠加验证码或 MFA - 采用登录标识+IP 而不是全局账号锁定,避免攻击者用错误密码把他人账号整体锁死;如仍有分布式暴力破解风险,建议叠加验证码或 MFA
- 成功登录会清除本次输入标识+IP 和实际账号+IP 的失败计数;同一 IP 的计数保留到窗口结束,防止在多个账号之间轮换尝试 - 成功登录会清除本次输入标识+IP 和实际账号+IP 的失败计数;同一 IP 的计数保留到窗口结束,防止在多个账号之间轮换尝试
- 审计事件覆盖:登录成功/失败/限流、退出登录、用户新增/修改/重置密码/删除、应用新增/修改/删除/重置密钥/查看密钥、修改接入信息、用户添加/移除应用 - 账号密码和数字证书登录成功时,都会把来源 IP 与登录方式写入会话;之后每个请求都会校验当前 IP。IP 发生变化时,立即撤销当前会话、该用户全部门户会话及全部 OAuth 授权,并记录“登录IP变更”审计;子系统会在下一次 UserInfo 重校验(默认 60 秒)时退出
- 登录失败/限流审计中的身份证号、手机号只保留前后部分用于定位,其余脱敏;成功登录记录实际用户名 - 审计事件覆盖:登录成功/失败/限流、登录IP变更、退出登录、用户新增/修改/重置密码/删除、应用新增/修改/删除/重置密钥/查看密钥、修改接入信息、用户添加/移除应用
- 登录审计会记录登录方式(账号密码/数字证书)与来源 IP;登录失败/限流审计中的身份证号、手机号只保留前后部分用于定位,其余脱敏;成功登录记录实际用户名
- 管理后台 → 审计日志,支持事件类型、关键字、时间范围筛选;详情不会记录密码、客户端密钥或令牌 - 管理后台 → 审计日志,支持事件类型、关键字、时间范围筛选;详情不会记录密码、客户端密钥或令牌
- 用户角色变更、停用、删除、重置密码、修改登录标识或退出登录时,立即撤销其 Redis 会话与 OAuth 授权;应用下架或用户移除应用时,撤销对应授权
- 启动日志只提示已创建默认账号,不打印任何默认密码、客户端密钥或用户口令
- 审计表 `audit_log` 只提供写入和查询,不提供删除接口;生产环境需按合规要求定期归档 - 审计表 `audit_log` 只提供写入和查询,不提供删除接口;生产环境需按合规要求定期归档
## 批量导入用户 ## 批量导入用户
- 管理后台 → 用户管理 → 批量导入;先下载模板,按表头填写。用户名、密码、姓名为必填列,警号(普通用户)、身份证号、邮箱、手机号、角色、状态等按模板说明填写 - 管理后台 → 用户管理 → 批量导入;先下载模板,按表头填写。用户名、密码、姓名、身份证号、手机号为必填列,警号(普通用户)、邮箱、角色、状态等按模板说明填写
- 仅接受 `.xlsx`,文件不超过 5MB,单次最多 200 行;文件只在内存中解析,不写入磁盘 - 仅接受 `.xlsx`,文件不超过 5MB,单次最多 5000 行;文件只在内存中解析,不写入磁盘
- 角色支持 `ADMIN` / `USER` 或“管理员” / “普通用户”;状态支持 `1` / `0` 或“启用” / “停用”,留空默认启用 - 角色支持 `ADMIN` / `USER` 或“管理员” / “普通用户”;状态支持 `1` / `0` 或“启用” / “停用”,留空默认启用
- 每行独立校验:用户名格式、用户名重复、密码 8-64 位、邮箱格式、身份证号格式与重复、手机号格式与重复等;失败行返回行号和原因,其他行继续导入 - 每行独立校验:用户名格式、用户名重复、密码 8-64 位、邮箱格式、身份证号必填/格式/重复、手机号必填/格式/重复等;失败行返回行号和原因,其他行继续导入
- Excel 公式单元格会被拒绝;密码、身份证号和手机号建议设置为文本格式,避免 Excel 自动转换 - 模板已将用户名、密码、警号、身份证号、手机号列预设为文本格式;这些列若以数值格式提交会被拒绝,避免身份证号丢精度或警号丢失前导零
- 新导入账号首次登录必须修改初始密码,且新密码不能与原密码相同;修改前只能访问改密页
- 导入后 30 天内从未登录的账号自动停用;登录过之后按所有账号的 90 天规则处理。`admin` 不受自动停用规则影响。每日 03:17 执行检查,自动停用会撤销会话和 OAuth 授权并写入审计日志
- 批量删除在用户列表中勾选(不能删除自己),会同时清理被删用户的应用选择和个人布局 - 批量删除在用户列表中勾选(不能删除自己),会同时清理被删用户的应用选择和个人布局
- 批量导入和批量删除都会写入审计日志 - 批量导入和批量删除都会写入审计日志
...@@ -271,19 +283,19 @@ client_secret 始终只在服务端使用,浏览器全程接触不到。 ...@@ -271,19 +283,19 @@ client_secret 始终只在服务端使用,浏览器全程接触不到。
- MySQL 使用独立账号并限制来源;Redis 开启密码/ACL,不要暴露到公网 - MySQL 使用独立账号并限制来源;Redis 开启密码/ACL,不要暴露到公网
- 备份 `sso_jwk` 表:私钥丢失会导致所有已签发令牌失效;建议对私钥列做静态加密或使用 KMS - 备份 `sso_jwk` 表:私钥丢失会导致所有已签发令牌失效;建议对私钥列做静态加密或使用 KMS
- 按实际业务调整登录限流阈值;多实例部署时共享 Redis 即可统一计数 - 按实际业务调整登录限流阈值;多实例部署时共享 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 - 多实例部署时保持 `ZHIHUI_ISSUER` 一致,并共享 MySQL/Redis
## 已知限制与后续方向 ## 已知限制与后续方向
- **单点退出范围**:退出门户会结束统一认证会话;各子系统本地会话仍保留到过期。若需要“一处退出、处处退出”,可增加 OIDC Back-Channel Logout 或子系统定期校验会话。 - **单点退出范围**:退出门户会结束统一认证会话并撤销 OAuth 授权;已接入 Starter 的子系统会在下一次会话重校验(默认 60 秒)时结束本地会话。若需要即时全局退出,可进一步接入 OIDC Back-Channel Logout。
- **权限模型**:目前只有 `ADMIN` / `USER` 两种角色,尚未实现菜单级/数据级 RBAC、组织架构。 - **权限模型**:目前只有 `ADMIN` / `USER` 两种角色,尚未实现菜单级/数据级 RBAC、组织架构。
- **浏览器纯前端接入**:当前客户端都是带密钥的机密客户端;Vue/React 纯 SPA 应使用 BFF 模式或由后端接入,暂不支持无密钥的公共客户端。 - **浏览器纯前端接入**:当前客户端都是带密钥的机密客户端;Vue/React 纯 SPA 应使用 BFF 模式或由后端接入,暂不支持无密钥的公共客户端。
- **登录安全**:未内置验证码、MFA、AD/LDAP/企业微信等身份源;登录失败限流已内置,默认按登录标识+IP 和 IP 两个维度计数。 - **登录安全**:未内置验证码、MFA、AD/LDAP/企业微信等身份源;登录失败限流已内置,默认按登录标识+IP 和 IP 两个维度计数。
- **审计留存**:审计日志当前存 MySQL,未做 WORM/外部 SIEM 对接,生产环境需按合规要求归档。 - **审计留存**:审计日志当前存 MySQL,未做 WORM/外部 SIEM 对接,生产环境需按合规要求归档。
- **批量导入**:当前为同步执行,单次最多 200 行;更大规模的数据迁移建议增加异步任务和进度查询。 - **批量导入**:当前为同步执行,单次最多 5000 行;更大规模的数据迁移建议增加异步任务和进度查询。
- **前端体积**:Element Plus 为全量引入,构建产物约 1.1 MB(gzip 约 380 KB),后续可按需引入减包。 - **前端体积**:Element Plus 为全量引入,构建产物约 1.1 MB(gzip 约 380 KB),后续可按需引入减包。
- **认证中心拆分**:目前与门户同进程;流量增大后可拆成独立服务,OIDC 端点无需变化。 - **认证中心拆分**:目前与门户同进程;流量增大后可拆成独立服务,OIDC 端点无需变化。
...@@ -329,10 +341,16 @@ CSRF 校验失败。正常浏览器表单不会出现;用 curl 测试时需要 ...@@ -329,10 +341,16 @@ CSRF 校验失败。正常浏览器表单不会出现;用 curl 测试时需要
- 子系统与认证中心不在同一浏览器上下文(例如一个 HTTPS、一个 HTTP) - 子系统与认证中心不在同一浏览器上下文(例如一个 HTTPS、一个 HTTP)
- 服务器时间偏差过大导致 `exp` 校验失败 - 服务器时间偏差过大导致 `exp` 校验失败
**admin/demo 点击应用提示“当前账号未开通本系统”** **admin/demo 点击应用显示“无法访问应用”(403)**
这是预期行为,和普通账号没有本地权限时一致。`admin`、`demo` 是门户管理账号,门户不会为它们签发警号、身份证号、手机号 claims,子系统的本地用户校验会拒绝。请使用普通账号的对应标识登录后再进入;子系统也不应以用户名或 `ADMIN` 角色给这两个账号开例外。 这是预期行为。`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 @@ ...@@ -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 @@ ...@@ -26,9 +26,9 @@
| 账号 | 门户登录方式 | 子系统访问规则 | | 账号 | 门户登录方式 | 子系统访问规则 |
| --- | --- | --- | | --- | --- | --- |
| `admin` | 仅用户名 | 不签发三个身份 claims,由子系统按普通未开通账号拒绝 | | `admin` | 仅用户名 | 门户授权端点直接拒绝,不签发授权码 |
| `demo` | 仅用户名 | 不签发三个身份 claims,由子系统按普通未开通账号拒绝 | | `demo` | 仅用户名 | 门户授权端点直接拒绝,不签发授权码 |
| 其他账号(含其他 `ADMIN` 角色账号) | 警号 / 身份证号 / 手机号 | 按 `police_number`、`id_card_number`、`phone_number` 任一标识匹配本地用户,无管理员绕过 | | 其他账号(含其他 `ADMIN` 角色账号) | 警号 / 身份证号 / 手机号 | 先将应用添加到个人门户,再按三个标识匹配本地用户,无管理员绕过 |
登录页只有一个“账号”输入框,按输入格式自动判断: 登录页只有一个“账号”输入框,按输入格式自动判断:
...@@ -48,17 +48,17 @@ ...@@ -48,17 +48,17 @@
- 三个 claim 需要 `profile` scope;Starter 默认已申请。子系统应取非空标识查询本地用户,三个标识都没有时直接拒绝。 - 三个 claim 需要 `profile` scope;Starter 默认已申请。子系统应取非空标识查询本地用户,三个标识都没有时直接拒绝。
- 登录页使用的是哪种标识,不影响三个 claim 的内容;只要用户填写了对应字段,claims 就会带上。 - 登录页使用的是哪种标识,不影响三个 claim 的内容;只要用户填写了对应字段,claims 就会带上。
- `admin`、`demo` 不能通过任何 `ADMIN` 角色绕过:门户不会为它们签发身份 claims,子系统按普通未开通账号处理;其他 `ADMIN` 角色账号也必须按三个标识匹配本地用户。 - `admin`、`demo` 不能通过任何 `ADMIN` 角色绕过:门户授权端点直接拒绝它们;其他 `ADMIN` 角色账号也必须先把应用添加到个人门户,并按三个标识匹配本地用户。
- 如果三个标识同时命中不同本地用户,属于身份歧义,必须拒绝访问。 - 如果三个标识同时命中不同本地用户,属于身份歧义,必须拒绝访问。
- 如果某个警号本身恰好是 18 位身份证格式或 11 位手机号格式,登录时会被当作身份证号/手机号查询,应避免使用这类警号。 - 如果某个警号本身恰好是 18 位身份证格式或 11 位手机号格式,登录时会被当作身份证号/手机号查询,应避免使用这类警号。
- 维护路径:管理后台 → 用户管理 → 编辑;Excel 导入模板已包含“身份证号”“手机号”列。 - 维护路径:管理后台 → 用户管理 → 编辑;Excel 导入模板已包含“身份证号”“手机号”列,批量导入时两列均为必填。
- 升级提示:V13 会清理升级前已保存的 `admin` / `demo` OAuth2 授权;当前版本它们仍可完成授权,但不会获得三个身份 claims,子系统会按普通未开通账号拒绝。普通用户从旧版本升级后建议重新登录一次,让新 ID Token 带上 `id_card_number`、`phone_number`。 - 升级提示:V13 会清理升级前已保存的 `admin` / `demo` OAuth2 授权;当前版本门户授权端点直接拒绝这两个账号,不会签发授权码。普通用户从旧版本升级后建议重新登录一次,让新 ID Token 带上 `id_card_number`、`phone_number`。
--- ---
## 0. 在门户创建应用 ## 0. 在门户创建应用
1. 管理员登录门户 → 管理后台 → 市场管理 → 新增应用 1. 管理员登录门户 → 管理后台 → 智能体广场管理 → 新增应用
2. 填写: 2. 填写:
- 应用名称、应用编码(小写字母/数字/中划线) - 应用名称、应用编码(小写字母/数字/中划线)
- 访问地址:用户点击应用时打开的地址 - 访问地址:用户点击应用时打开的地址
...@@ -93,13 +93,15 @@ ...@@ -93,13 +93,15 @@
1. 子系统生成并保存 `state`、`nonce`(服务端 OIDC 库通常自动处理) 1. 子系统生成并保存 `state`、`nonce`(服务端 OIDC 库通常自动处理)
2. 浏览器跳转 `{issuer}/oauth2/authorize`,携带 `response_type=code`、`client_id`、`redirect_uri`、`scope`、`state`、`nonce` 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=...` 4. 回调 `redirect_uri?code=...&state=...`
5. 子系统校验 `state`,在**服务端**用授权码 + `client_id` + `client_secret` 调用 `/oauth2/token` 5. 子系统校验 `state`,在**服务端**用授权码 + `client_id` + `client_secret` 调用 `/oauth2/token`
6. 校验 ID Token:JWKS 签名、`iss`、`aud`、`exp`、`nonce` 6. 校验 ID Token:JWKS 签名、`iss`、`aud`、`exp`、`nonce`
7. 从已校验的 claims 中读取 `police_number`、`id_card_number`、`phone_number`;`preferred_username`、`name` 等只用于展示 7. 从已校验的 claims 中读取 `police_number`、`id_card_number`、`phone_number`;`preferred_username`、`name` 等只用于展示
8. 用三个标识中的任一非空值查询本地用户表,校验账号存在且启用;三者都未提供、账号不存在/停用,或同时命中多个本地用户时拒绝访问。`admin` / `demo` 没有身份 claims,会在这里按普通未开通账号被拒绝,不需要也不应为它们开例外 8. 用三个标识中的任一非空值查询本地用户表,校验账号存在且启用;三者都未提供、账号不存在/停用,或同时命中多个本地用户时拒绝访问。`admin` / `demo` 已在门户授权阶段被拒绝,子系统不应为它们开例外
9. 校验通过后写入子系统自己的 Session;未通过则返回 403 或跳转子系统提示页 9. 校验通过后写入子系统自己的 Session;未通过则返回 403 或跳转子系统提示页。使用 Java Starter 时会默认每 60 秒通过 UserInfo 重新校验会话
> 门户登录会话会绑定登录时的客户端 IP;账号密码和数字证书登录都会记录登录方式与来源 IP。IP 变化时门户会撤销该用户全部门户会话和 OAuth 授权,子系统在下一次 UserInfo 重校验时会退出。接入方无需自行实现 IP 绑定,也不要把门户登录 IP 当作业务权限依据。
`client_secret` 只出现在服务端,浏览器全程接触不到。纯前端 SPA 无法安全保存它,应使用 BFF 或由后端接入。 `client_secret` 只出现在服务端,浏览器全程接触不到。纯前端 SPA 无法安全保存它,应使用 BFF 或由后端接入。
...@@ -273,7 +275,7 @@ exit; ...@@ -273,7 +275,7 @@ exit;
- 登录成功后调用 `session_regenerate_id(true)`,避免会话固定 - 登录成功后调用 `session_regenerate_id(true)`,避免会话固定
- 不要只解析 ID Token 而不校验签名、`iss`、`aud`、`exp` - 不要只解析 ID Token 而不校验签名、`iss`、`aud`、`exp`
- SSO 只完成身份认证,登录成功后仍需查询本地用户表;不要自动放行所有门户用户 - SSO 只完成身份认证,登录成功后仍需查询本地用户表;不要自动放行所有门户用户
- 当前单点退出只结束门户认证会话;PHP 本地 Session 需要自己销毁 - 门户退出会结束认证会话并撤销 OAuth 授权;非 Starter 接入(如 PHP)的本地 Session 需要自己销毁,并在后续请求中重新校验
--- ---
...@@ -453,6 +455,8 @@ zhihui: ...@@ -453,6 +455,8 @@ zhihui:
client-secret: ${ZHIHUI_CLIENT_SECRET} client-secret: ${ZHIHUI_CLIENT_SECRET}
redirect-uri: http://localhost:8081/sso/callback redirect-uri: http://localhost:8081/sso/callback
post-logout-redirect-uri: http://localhost:8081/ post-logout-redirect-uri: http://localhost:8081/
# 已登录会话通过 UserInfo 重新校验的间隔;0 表示关闭
session-revalidate-interval: 60s
exclude-patterns: exclude-patterns:
- /css/** - /css/**
- /js/** - /js/**
...@@ -468,6 +472,7 @@ zhihui: ...@@ -468,6 +472,7 @@ zhihui:
| `redirect-uri` | 是 | — | 必须与后台回调地址完全一致 | | `redirect-uri` | 是 | — | 必须与后台回调地址完全一致 |
| `post-logout-redirect-uri` | 否 | — | 退出后回到的地址,需在后台登记 | | `post-logout-redirect-uri` | 否 | — | 退出后回到的地址,需在后台登记 |
| `scopes` | 否 | `openid profile email` | 申请的 scope | | `scopes` | 否 | `openid profile email` | 申请的 scope |
| `session-revalidate-interval` | 否 | `60s` | 已登录会话通过 UserInfo 重校验的间隔;设为 `0` 关闭 |
| `exclude-patterns` | 否 | 常见静态资源 | 不拦截的 Ant 风格路径 | | `exclude-patterns` | 否 | 常见静态资源 | 不拦截的 Ant 风格路径 |
| `login-path` | 否 | `/sso/login` | 手动触发登录 | | `login-path` | 否 | `/sso/login` | 手动触发登录 |
| `callback-path` | 否 | `/sso/callback` | 回调路径 | | `callback-path` | 否 | `/sso/callback` | 回调路径 |
...@@ -542,11 +547,11 @@ if (!localUserService.existsActiveByIdentifiers( ...@@ -542,11 +547,11 @@ if (!localUserService.existsActiveByIdentifiers(
必须用 `ssoUser.getPoliceNumber()`、`ssoUser.getIdCardNumber()`、`ssoUser.getPhoneNumber()` 中的任一非空标识关联本地用户表;`username`、`subject`、`roles` 只用于展示,不能作为授权依据。三个标识都为空、本地用户不存在/停用,或同时命中多个本地用户时都要拒绝访问。是否允许首次登录自动建号由子系统决定,但不能因为 SSO 成功就跳过本地校验。 必须用 `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。 门户签发的 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 ```yaml
zhihui: zhihui:
...@@ -563,6 +568,16 @@ zhihui: ...@@ -563,6 +568,16 @@ zhihui:
普通用户的校验规则是“三个标识中任一匹配 + 账号启用”;不要求登录名一致,也不给 `ADMIN` 角色开例外。三个标识都为空、未开通/停用,或同时命中多个本地用户时跳转子系统的 `/no-local-user` 页面。生产系统应把这份读取逻辑替换为查询自己的用户表或权限系统。 普通用户的校验规则是“三个标识中任一匹配 + 账号启用”;不要求登录名一致,也不给 `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: ...@@ -579,6 +594,7 @@ zhihui:
- 静态资源和 `exclude-patterns` 不拦截 - 静态资源和 `exclude-patterns` 不拦截
- `/sso/login?redirect=/目标路径` 可手动触发登录 - `/sso/login?redirect=/目标路径` 可手动触发登录
- 已登录但本地用户不存在或被停用:由子系统自己的用户校验逻辑返回 403 或跳转提示页 - 已登录但本地用户不存在或被停用:由子系统自己的用户校验逻辑返回 403 或跳转提示页
- 门户停用/删除用户、修改身份标识、移除应用或退出:Starter 会在下一次 UserInfo 重校验时销毁本地会话
- 多实例部署时,`SsoUser` 已实现 `Serializable`;建议配合 Spring Session(Redis/JDBC),否则需要会话粘滞 - 多实例部署时,`SsoUser` 已实现 `Serializable`;建议配合 Spring Session(Redis/JDBC),否则需要会话粘滞
#### 与现有 Spring Security 共存 #### 与现有 Spring Security 共存
...@@ -661,7 +677,7 @@ public String index(@AuthenticationPrincipal OidcUser user) { ...@@ -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}/` 作为登出回跳地址,并确保已在后台登记。 退出可以使用 `OidcClientInitiatedLogoutSuccessHandler`,把 `{baseUrl}/` 作为登出回跳地址,并确保已在后台登记。
...@@ -694,7 +710,7 @@ Authorization: Basic base64(urlencode(client_id):urlencode(client_secret)) ...@@ -694,7 +710,7 @@ Authorization: Basic base64(urlencode(client_id):urlencode(client_secret))
- `nonce` 与第一步一致 - `nonce` 与第一步一致
7. 从已验证的 claims 中读取 `police_number`、`id_card_number`、`phone_number`;`preferred_username`、`uid` 只用于展示 7. 从已验证的 claims 中读取 `police_number`、`id_card_number`、`phone_number`;`preferred_username`、`uid` 只用于展示
8. 用三个标识中的任一非空值查询本地用户表,确认账号存在且启用;三者都为空、未命中或同时命中多个本地用户时拒绝。`admin` / `demo` 没有身份 claims,会按普通未开通账号被拒绝 8. 用三个标识中的任一非空值查询本地用户表,确认账号存在且启用;三者都为空、未命中或同时命中多个本地用户时拒绝。`admin` / `demo` 在门户授权端点即被拒绝,不会进入子系统
9. 校验通过后建立本地会话,`state` / `nonce` / `code_verifier` 用完即删 9. 校验通过后建立本地会话,`state` / `nonce` / `code_verifier` 用完即删
不要: 不要:
...@@ -728,7 +744,7 @@ ID Token / UserInfo 可用字段: ...@@ -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。 `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 可用字段: ...@@ -752,10 +768,9 @@ ID Token / UserInfo 可用字段:
当前实现 **RP-Initiated Logout**: 当前实现 **RP-Initiated Logout**:
- 子系统退出:结束子系统本地会话 + 门户认证会话 - 子系统退出:结束子系统本地会话 + 门户认证会话
- 门户退出:结束门户认证会话 - 门户退出:结束门户认证会话,并撤销该用户已有的 OAuth 授权
- 其他子系统的本地会话不会立即失效,会保留到各自超时 - Java Starter 默认每 60 秒通过 UserInfo 重校验;门户用户停用、删除、身份标识变更、移除应用或退出后,子系统会在下一次重校验时结束本地会话
- 已有自包含 JWT Access Token 在过期前仍可能被其他资源服务器接受;标准 OIDC 客户端应自行实现 UserInfo 校验、短期 Token 或 Back-Channel Logout
如果需要“一处退出、处处退出”,需要增加 OIDC Back-Channel Logout,或在子系统采用短期会话/定期校验。
--- ---
...@@ -769,9 +784,11 @@ ID Token / UserInfo 可用字段: ...@@ -769,9 +784,11 @@ ID Token / UserInfo 可用字段:
- [ ] 服务端时间已同步(NTP) - [ ] 服务端时间已同步(NTP)
- [ ] `client_secret` 只在服务端,通过环境变量/密钥管理注入 - [ ] `client_secret` 只在服务端,通过环境变量/密钥管理注入
- [ ] 子系统会话 Cookie 设置 HttpOnly、SameSite=Lax 或更严格 - [ ] 子系统会话 Cookie 设置 HttpOnly、SameSite=Lax 或更严格
- [ ] 应用处于上架状态,且业务用户已在个人门户中添加该应用
- [ ] 普通用户已使用 `police_number`、`id_card_number`、`phone_number` 中的任一标识查询本地用户表;三者都为空、账号不存在/停用或多用户命中时拒绝访问 - [ ] 普通用户已使用 `police_number`、`id_card_number`、`phone_number` 中的任一标识查询本地用户表;三者都为空、账号不存在/停用或多用户命中时拒绝访问
- [ ] 子系统没有使用用户名、`sub`、`roles` 或显示名作为授权依据 - [ ] 子系统没有使用用户名、`sub`、`roles` 或显示名作为授权依据
- [ ] 已确认 `admin` / `demo` 被门户拒绝,且子系统没有为它们保留管理员绕过 - [ ] 已确认 `admin` / `demo` 被门户授权端点拒绝,且子系统没有为它们保留管理员绕过
- [ ] Java Starter 的 `session-revalidate-interval` 已按业务安全要求设置(默认 60 秒)
- [ ] 已确认单点退出范围符合预期 - [ ] 已确认单点退出范围符合预期
--- ---
...@@ -790,7 +807,9 @@ ID Token / UserInfo 可用字段: ...@@ -790,7 +807,9 @@ ID Token / UserInfo 可用字段:
| 身份证号/手机号无法登录 | 对应用户未填写该字段、格式不是 18 位身份证号或 11 位手机号,或账号已停用 | | 身份证号/手机号无法登录 | 对应用户未填写该字段、格式不是 18 位身份证号或 11 位手机号,或账号已停用 |
| 子系统 401 但浏览器有登录态 | Cookie 不在同一浏览器上下文,或 Starter 的 `exclude-patterns` 配错 | | 子系统 401 但浏览器有登录态 | Cookie 不在同一浏览器上下文,或 Starter 的 `exclude-patterns` 配错 |
| 登录成功但显示“未开通本系统” | 本地用户表没有匹配该账号任一标识的记录、账号已停用、同时命中多个本地用户,或 ID Token 缺少三个身份 claims | | 登录成功但显示“未开通本系统” | 本地用户表没有匹配该账号任一标识的记录、账号已停用、同时命中多个本地用户,或 ID Token 缺少三个身份 claims |
| OAuth 授权返回 403 | 应用未上架、当前用户未把应用添加到个人门户,或账号是 `admin` / `demo` |
| 子系统使用中突然回到登录页 | 门户账号被停用/删除、身份标识变更、应用被移除/下架,或门户会话已退出;Starter 的 UserInfo 重校验失败后会重新登录 |
| 旧版本升级后看不到身份证号/手机号 claims | 旧授权记录或旧 Token 尚未刷新;退出子系统并重新登录,或等待旧 Token 过期 | | 旧版本升级后看不到身份证号/手机号 claims | 旧授权记录或旧 Token 尚未刷新;退出子系统并重新登录,或等待旧 Token 过期 |
| admin/demo 无法进入子系统 | 预期行为:门户不签发三个身份 claims,子系统按普通未开通账号提示无权限 | | admin/demo 无法进入子系统 | 预期行为:门户授权端点直接返回 403,不会签发授权码 |
更完整的部署、安全与故障排查见项目根目录 [README.md](../README.md)。 更完整的部署、安全与故障排查见项目根目录 [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" ...@@ -16,7 +16,7 @@ DEMO_CALLBACK="${DEMO_URL}/sso/callback"
SMOKE_USER="${ZHIHUI_SMOKE_USER:-smoke_subsystem}" SMOKE_USER="${ZHIHUI_SMOKE_USER:-smoke_subsystem}"
SMOKE_PASSWORD="${ZHIHUI_SMOKE_PASSWORD:-Smoke@123}" SMOKE_PASSWORD="${ZHIHUI_SMOKE_PASSWORD:-Smoke@123}"
SMOKE_POLICE="${ZHIHUI_SMOKE_POLICE:-SMK660909}" 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}" SMOKE_PHONE="${ZHIHUI_SMOKE_PHONE:-13800138000}"
TMP="$(mktemp -d)" TMP="$(mktemp -d)"
...@@ -104,24 +104,6 @@ exchange_code() { ...@@ -104,24 +104,6 @@ exchange_code() {
-o "$output" || fail "令牌交换失败" -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. 健康检查 ==" echo "== 1. 健康检查 =="
curl -fsS --max-time 5 "$BASE_URL/actuator/health" >/dev/null || fail "门户未启动" curl -fsS --max-time 5 "$BASE_URL/actuator/health" >/dev/null || fail "门户未启动"
pass "门户可访问:$BASE_URL" pass "门户可访问:$BASE_URL"
...@@ -131,45 +113,35 @@ login "$ADMIN_JAR" "$ADMIN_USER" "$ADMIN_PASSWORD" ...@@ -131,45 +113,35 @@ login "$ADMIN_JAR" "$ADMIN_USER" "$ADMIN_PASSWORD"
[ "$(me_username "$ADMIN_JAR")" = "$ADMIN_USER" ] || fail "/api/me 返回异常" [ "$(me_username "$ADMIN_JAR")" = "$ADMIN_USER" ] || fail "/api/me 返回异常"
pass "登录成功:$ADMIN_USER" pass "登录成功:$ADMIN_USER"
echo "== 3. admin 进入子系统由本地校验拒绝 ==" echo "== 3. admin 请求子系统授权由门户拒绝 =="
STATUS="$(authorize_status "$ADMIN_JAR" "$TMP/admin-authz.html")" STATUS="$(authorize_status "$ADMIN_JAR" "$TMP/admin-authz.html")"
[ "$STATUS" = "302" ] || fail "admin 未获得授权跳转(HTTP $STATUS)" [ "$STATUS" = "403" ] || fail "admin 未按预期被门户拒绝(HTTP $STATUS)"
LOCATION="$(grep -i '^location:' "$TMP/authz.headers" | sed 's/^[Ll]ocation: //' | tr -d '\r')" pass "admin 请求 OAuth 授权被门户拒绝(403)"
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"
if curl -s --max-time 3 -o /dev/null "$DEMO_URL/"; then if curl -s --max-time 3 -o /dev/null "$DEMO_URL/"; then
cp "$ADMIN_JAR" "$TMP/admin-demo-cookies" 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" \ 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/")" -H "Accept: text/html" -o "$TMP/admin-demo.html" -w '%{http_code}' "$DEMO_URL/")"
[ "$FINAL_STATUS" = "200" ] || fail "admin 访问示例子系统返回 HTTP $FINAL_STATUS" [ "$FINAL_STATUS" = "403" ] || fail "admin 访问示例子系统返回 HTTP $FINAL_STATUS"
grep -q "当前账号未开通本系统" "$TMP/admin-demo.html" \ grep -q "无法访问应用" "$TMP/admin-demo.html" \
|| fail "admin 未显示与普通账号一致的未开通提示" || fail "admin 未显示门户拒绝页面"
pass "admin 进入子系统时显示普通账号同款无权限提示" pass "admin 进入子系统时被门户拒绝"
else else
echo " [SKIP] 示例子系统未启动($DEMO_URL),跳过 admin 页面校验" echo " [SKIP] 示例子系统未启动($DEMO_URL),跳过 admin 页面校验"
fi fi
echo "== 4. demo 进入子系统由本地校验拒绝 ==" echo "== 4. demo 请求子系统授权由门户拒绝 =="
login "$TMP/demo.cookies" "$DEMO_USER" "$DEMO_PASSWORD" login "$TMP/demo.cookies" "$DEMO_USER" "$DEMO_PASSWORD"
STATUS="$(authorize_status "$TMP/demo.cookies" "$TMP/demo-authz.html")" STATUS="$(authorize_status "$TMP/demo.cookies" "$TMP/demo-authz.html")"
[ "$STATUS" = "302" ] || fail "demo 未获得授权跳转(HTTP $STATUS)" [ "$STATUS" = "403" ] || fail "demo 未按预期被门户拒绝(HTTP $STATUS)"
LOCATION="$(grep -i '^location:' "$TMP/authz.headers" | sed 's/^[Ll]ocation: //' | tr -d '\r')" pass "demo 请求 OAuth 授权被门户拒绝(403)"
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"
if curl -s --max-time 3 -o /dev/null "$DEMO2_URL/"; then if curl -s --max-time 3 -o /dev/null "$DEMO2_URL/"; then
cp "$TMP/demo.cookies" "$TMP/demo2-cookies" cp "$TMP/demo.cookies" "$TMP/demo2-cookies"
FINAL_STATUS="$(curl -s -L --max-redirs 10 -b "$TMP/demo2-cookies" -c "$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/")" -H "Accept: text/html" -o "$TMP/demo-no-local.html" -w '%{http_code}' "$DEMO2_URL/")"
[ "$FINAL_STATUS" = "200" ] || fail "demo 访问第二个子系统返回 HTTP $FINAL_STATUS" [ "$FINAL_STATUS" = "403" ] || fail "demo 访问第二个子系统返回 HTTP $FINAL_STATUS"
grep -q "当前账号未开通本系统" "$TMP/demo-no-local.html" \ grep -q "无法访问应用" "$TMP/demo-no-local.html" \
|| fail "demo 未显示与普通账号一致的未开通提示" || fail "demo 未显示门户拒绝页面"
pass "demo 进入子系统时显示普通账号同款无权限提示" pass "demo 进入子系统时被门户拒绝"
else else
echo " [SKIP] 第二个子系统未启动($DEMO2_URL),跳过 demo 页面校验" echo " [SKIP] 第二个子系统未启动($DEMO2_URL),跳过 demo 页面校验"
fi fi
...@@ -215,6 +187,22 @@ login "$SMOKE_JAR" "$SMOKE_POLICE" "$SMOKE_PASSWORD" ...@@ -215,6 +187,22 @@ login "$SMOKE_JAR" "$SMOKE_POLICE" "$SMOKE_PASSWORD"
[ "$(me_username "$SMOKE_JAR")" = "$SMOKE_USER" ] || fail "普通账号 /api/me 返回异常" [ "$(me_username "$SMOKE_JAR")" = "$SMOKE_USER" ] || fail "普通账号 /api/me 返回异常"
pass "普通账号可用警号登录" 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 授权 ==" echo "== 7. 普通账号 OIDC 授权 =="
STATUS="$(authorize_status "$SMOKE_JAR" "$TMP/smoke-authz.html")" STATUS="$(authorize_status "$SMOKE_JAR" "$TMP/smoke-authz.html")"
[ "$STATUS" = "302" ] || fail "普通账号未获得授权跳转(HTTP $STATUS)" [ "$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: ...@@ -31,6 +31,7 @@ zhihui:
client-secret: ${ZHIHUI_DEMO_CLIENT_SECRET:demo-secret-2026} client-secret: ${ZHIHUI_DEMO_CLIENT_SECRET:demo-secret-2026}
redirect-uri: ${ZHIHUI_DEMO_REDIRECT_URI:http://localhost:8081/sso/callback} redirect-uri: ${ZHIHUI_DEMO_REDIRECT_URI:http://localhost:8081/sso/callback}
post-logout-redirect-uri: ${ZHIHUI_DEMO_POST_LOGOUT_URI:http://localhost:8081/} post-logout-redirect-uri: ${ZHIHUI_DEMO_POST_LOGOUT_URI:http://localhost:8081/}
session-revalidate-interval: ${ZHIHUI_SSO_SESSION_REVALIDATE_INTERVAL:60s}
logging: logging:
level: level:
......
package com.zhihui.portal; package com.zhihui.portal;
import com.zhihui.portal.cert.CertificateAuthProperties;
import com.zhihui.portal.config.PortalSecurityProperties; import com.zhihui.portal.config.PortalSecurityProperties;
import com.zhihui.portal.config.ZhihuiProperties; import com.zhihui.portal.config.ZhihuiProperties;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableConfigurationProperties({ZhihuiProperties.class, PortalSecurityProperties.class}) @EnableScheduling
@EnableConfigurationProperties({ZhihuiProperties.class, PortalSecurityProperties.class,
CertificateAuthProperties.class})
public class ZhihuiApplication { public class ZhihuiApplication {
public static void main(String[] args) { 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; ...@@ -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.UpdateAppRequest;
import com.zhihui.portal.app.dto.AppDtos.UpdateIntegrationRequest; import com.zhihui.portal.app.dto.AppDtos.UpdateIntegrationRequest;
import com.zhihui.portal.common.BizException; import com.zhihui.portal.common.BizException;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.AuthorizationGrantType;
...@@ -49,17 +50,20 @@ public class AppService { ...@@ -49,17 +50,20 @@ public class AppService {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final ClientSecretCipher secretCipher; private final ClientSecretCipher secretCipher;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public AppService(PortalAppRepository appRepository, public AppService(PortalAppRepository appRepository,
RegisteredClientRepository registeredClientRepository, RegisteredClientRepository registeredClientRepository,
PasswordEncoder passwordEncoder, PasswordEncoder passwordEncoder,
JdbcTemplate jdbcTemplate, JdbcTemplate jdbcTemplate,
ClientSecretCipher secretCipher) { ClientSecretCipher secretCipher,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.appRepository = appRepository; this.appRepository = appRepository;
this.registeredClientRepository = registeredClientRepository; this.registeredClientRepository = registeredClientRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.secretCipher = secretCipher; this.secretCipher = secretCipher;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
} }
public List<AppResponse> listAll() { public List<AppResponse> listAll() {
...@@ -69,7 +73,7 @@ public class AppService { ...@@ -69,7 +73,7 @@ public class AppService {
} }
/** /**
* 当前用户的门户:用户从市场添加的应用,按个人格子位置排列。 * 当前用户的门户:用户从智能体广场添加的应用,按个人格子位置排列。
*/ */
public List<AppCard> listCardsForUser(Long userId) { public List<AppCard> listCardsForUser(Long userId) {
List<PortalApp> active = appRepository.findAllByStatusOrderBySortOrderAscIdAsc(1); List<PortalApp> active = appRepository.findAllByStatusOrderBySortOrderAscIdAsc(1);
...@@ -122,7 +126,7 @@ public class AppService { ...@@ -122,7 +126,7 @@ public class AppService {
} }
/** /**
* 应用市场:所有启用中的应用,以及当前用户是否已经添加。 * 智能体广场:所有启用中的应用,以及当前用户是否已经添加。
*/ */
public List<MarketApp> listMarket(Long userId) { public List<MarketApp> listMarket(Long userId) {
Set<Long> selected = new HashSet<>(jdbcTemplate.queryForList( Set<Long> selected = new HashSet<>(jdbcTemplate.queryForList(
...@@ -175,14 +179,19 @@ public class AppService { ...@@ -175,14 +179,19 @@ public class AppService {
@Transactional @Transactional
public void hideAppForUser(Long userId, Long appId) { public void hideAppForUser(Long userId, Long appId) {
appRepository.findById(appId) PortalApp app = appRepository.findById(appId)
.filter(app -> app.getStatus() != null && app.getStatus() == 1) .filter(item -> item.getStatus() != null && item.getStatus() == 1)
.orElseThrow(() -> new BizException("应用不存在")); .orElseThrow(() -> new BizException("应用不存在"));
jdbcTemplate.update("DELETE FROM user_app_selection WHERE user_id = ? AND app_id = ?", jdbcTemplate.update("DELETE FROM user_app_selection WHERE user_id = ? AND app_id = ?",
userId, appId); userId, appId);
jdbcTemplate.update( jdbcTemplate.update(
"DELETE FROM user_launcher_order WHERE user_id = ? AND item_type = 'APP' AND item_id = ?", "DELETE FROM user_launcher_order WHERE user_id = ? AND item_type = 'APP' AND item_id = ?",
userId, appId); 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 @Transactional
...@@ -241,6 +250,7 @@ public class AppService { ...@@ -241,6 +250,7 @@ public class AppService {
@Transactional @Transactional
public AppResponse update(Long id, UpdateAppRequest request) { public AppResponse update(Long id, UpdateAppRequest request) {
PortalApp app = find(id); PortalApp app = find(id);
Integer oldStatus = app.getStatus();
String appUrl = validateUri(request.appUrl(), "访问地址"); String appUrl = validateUri(request.appUrl(), "访问地址");
List<String> redirectUris = normalizeRedirectUris(request.redirectUris(), appUrl); List<String> redirectUris = normalizeRedirectUris(request.redirectUris(), appUrl);
List<String> postLogoutUris = normalizePostLogoutUris(request.postLogoutRedirectUris(), appUrl); List<String> postLogoutUris = normalizePostLogoutUris(request.postLogoutRedirectUris(), appUrl);
...@@ -275,7 +285,11 @@ public class AppService { ...@@ -275,7 +285,11 @@ public class AppService {
} }
app.setStatus(request.status()); 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 @Transactional
......
...@@ -9,6 +9,8 @@ public interface PortalAppRepository extends JpaRepository<PortalApp, Long> { ...@@ -9,6 +9,8 @@ public interface PortalAppRepository extends JpaRepository<PortalApp, Long> {
Optional<PortalApp> findByAppCode(String appCode); Optional<PortalApp> findByAppCode(String appCode);
Optional<PortalApp> findByClientId(String clientId);
boolean existsByAppCode(String appCode); boolean existsByAppCode(String appCode);
List<PortalApp> findAllByStatusOrderBySortOrderAscIdAsc(Integer status); List<PortalApp> findAllByStatusOrderBySortOrderAscIdAsc(Integer status);
......
...@@ -5,11 +5,13 @@ public final class AuditDtos { ...@@ -5,11 +5,13 @@ public final class AuditDtos {
private AuditDtos() { private AuditDtos() {
} }
public record AuditLogEntry(Long id, String eventType, Long userId, String username, public record AuditLogEntry(Long id, String eventType, String loginType, Long userId,
String clientIp, String userAgent, String targetType, String username, String clientIp, String userAgent,
String targetId, String detail, boolean success, String createdAt) { 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 { ...@@ -5,10 +5,13 @@ public final class AuditEvents {
public static final String LOGIN_SUCCESS = "LOGIN_SUCCESS"; public static final String LOGIN_SUCCESS = "LOGIN_SUCCESS";
public static final String LOGIN_FAILURE = "LOGIN_FAILURE"; public static final String LOGIN_FAILURE = "LOGIN_FAILURE";
public static final String LOGIN_BLOCKED = "LOGIN_BLOCKED"; 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 LOGOUT = "LOGOUT";
public static final String USER_CREATE = "USER_CREATE"; public static final String USER_CREATE = "USER_CREATE";
public static final String USER_UPDATE = "USER_UPDATE"; 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_PASSWORD_RESET = "USER_PASSWORD_RESET";
public static final String USER_DELETE = "USER_DELETE"; public static final String USER_DELETE = "USER_DELETE";
public static final String USER_IMPORT = "USER_IMPORT"; public static final String USER_IMPORT = "USER_IMPORT";
......
...@@ -7,10 +7,12 @@ import com.zhihui.portal.common.BizException; ...@@ -7,10 +7,12 @@ import com.zhihui.portal.common.BizException;
import com.zhihui.portal.common.PageResponse; import com.zhihui.portal.common.PageResponse;
import com.zhihui.portal.security.ClientInfoResolver; import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.LoginIdentifierResolver; import com.zhihui.portal.security.LoginIdentifierResolver;
import com.zhihui.portal.security.LoginType;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowCallbackHandler;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
...@@ -25,7 +27,12 @@ import java.time.ZoneId; ...@@ -25,7 +27,12 @@ import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
@Service @Service
public class AuditLogService { public class AuditLogService {
...@@ -37,12 +44,12 @@ public class AuditLogService { ...@@ -37,12 +44,12 @@ public class AuditLogService {
private static final String INSERT_SQL = """ private static final String INSERT_SQL = """
INSERT INTO audit_log INSERT INTO audit_log
(event_type, user_id, username, client_ip, user_agent, target_type, target_id, detail, success, created_at) (event_type, login_type, user_id, username, client_ip, user_agent, target_type, target_id, detail, success, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""; """;
private static final String SELECT_SQL = """ 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 target_type, target_id, detail, success, created_at
FROM audit_log FROM audit_log
"""; """;
...@@ -54,12 +61,14 @@ public class AuditLogService { ...@@ -54,12 +61,14 @@ public class AuditLogService {
return new AuditLogEntry( return new AuditLogEntry(
rs.getLong("id"), rs.getLong("id"),
rs.getString("event_type"), rs.getString("event_type"),
rs.getString("login_type"),
rs.getObject("user_id", Long.class), rs.getObject("user_id", Long.class),
rs.getString("username"), rs.getString("username"),
rs.getString("client_ip"), rs.getString("client_ip"),
rs.getString("user_agent"), rs.getString("user_agent"),
rs.getString("target_type"), rs.getString("target_type"),
rs.getString("target_id"), rs.getString("target_id"),
null,
rs.getString("detail"), rs.getString("detail"),
rs.getBoolean("success"), rs.getBoolean("success"),
createdAtText); createdAtText);
...@@ -73,32 +82,45 @@ public class AuditLogService { ...@@ -73,32 +82,45 @@ public class AuditLogService {
this.clientInfoResolver = clientInfoResolver; this.clientInfoResolver = clientInfoResolver;
} }
public void recordLoginSuccess(SecurityUser user, String clientIp, String userAgent) { public void recordLoginSuccess(SecurityUser user, String clientIp, String userAgent,
record(AuditEvents.LOGIN_SUCCESS, String detail, LoginType loginType) {
record(AuditEvents.LOGIN_SUCCESS, loginType,
user == null ? null : user.getId(), user == null ? null : user.getId(),
user == null ? null : user.getUsername(), 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, public void recordLoginFailure(String username, String clientIp, String userAgent,
String reason, boolean blocked) { String reason, boolean blocked, LoginType loginType) {
String detail = "原因:" + reason + (blocked ? ";已触发登录限流" : ""); String detail = "原因:" + reason + (blocked ? ";已触发登录限流" : "");
record(AuditEvents.LOGIN_FAILURE, null, record(AuditEvents.LOGIN_FAILURE, loginType, null,
LoginIdentifierResolver.maskForAudit(username), LoginIdentifierResolver.maskForAudit(username),
clientIp, userAgent, "AUTH", null, detail, false); clientIp, userAgent, "AUTH", null, detail, false);
} }
public void recordLoginBlocked(String username, String clientIp, String userAgent, 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 scopeLabel = "USER_IP".equals(scope) ? "账号+IP" : "IP";
String detail = "限流维度:" + scopeLabel + ";剩余 " + retryAfterSeconds + " 秒"; String detail = "限流维度:" + scopeLabel + ";剩余 " + retryAfterSeconds + " 秒";
record(AuditEvents.LOGIN_BLOCKED, null, record(AuditEvents.LOGIN_BLOCKED, loginType, null,
LoginIdentifierResolver.maskForAudit(username), LoginIdentifierResolver.maskForAudit(username),
clientIp, userAgent, "AUTH", null, detail, false); 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) { 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.getId(),
user == null ? null : user.getUsername(), user == null ? null : user.getUsername(),
clientIp, userAgent, "AUTH", null, "退出登录", true); clientIp, userAgent, "AUTH", null, "退出登录", true);
...@@ -119,10 +141,14 @@ public class AuditLogService { ...@@ -119,10 +141,14 @@ public class AuditLogService {
HttpServletRequest request = currentRequest(); HttpServletRequest request = currentRequest();
String clientIp = clientInfoResolver.resolveIp(request); String clientIp = clientInfoResolver.resolveIp(request);
String userAgent = clientInfoResolver.resolveUserAgent(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); 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) { public List<LoginRecord> recentLoginRecords(Long userId, String username, int limit) {
int maxRows = Math.min(Math.max(limit, 1), 20); int maxRows = Math.min(Math.max(limit, 1), 20);
StringBuilder where = new StringBuilder( StringBuilder where = new StringBuilder(
...@@ -137,7 +163,8 @@ public class AuditLogService { ...@@ -137,7 +163,8 @@ public class AuditLogService {
params.add(maxRows); params.add(maxRows);
return jdbcTemplate.query(SELECT_SQL + where, ROW_MAPPER, params.toArray()).stream() 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(); .toList();
} }
...@@ -162,7 +189,10 @@ public class AuditLogService { ...@@ -162,7 +189,10 @@ public class AuditLogService {
term = term.substring(0, 100); term = term.substring(0, 100);
} }
String pattern = "%" + term + "%"; 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); params.add(pattern);
params.add(pattern); params.add(pattern);
...@@ -185,16 +215,59 @@ public class AuditLogService { ...@@ -185,16 +215,59 @@ public class AuditLogService {
List<AuditLogEntry> content = jdbcTemplate.query( List<AuditLogEntry> content = jdbcTemplate.query(
SELECT_SQL + where + " ORDER BY id DESC LIMIT ? OFFSET ?", SELECT_SQL + where + " ORDER BY id DESC LIMIT ? OFFSET ?",
ROW_MAPPER, queryParams.toArray()); ROW_MAPPER, queryParams.toArray());
content = resolveAppNames(content);
return new PageResponse<>(content, total == null ? 0 : total, pageNumber, pageSize); return new PageResponse<>(content, total == null ? 0 : total, pageNumber, pageSize);
} }
private void record(String eventType, Long userId, String username, String clientIp, private List<AuditLogEntry> resolveAppNames(List<AuditLogEntry> entries) {
String userAgent, String targetType, String targetId, 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) { String detail, boolean success) {
try { try {
jdbcTemplate.update(INSERT_SQL, jdbcTemplate.update(INSERT_SQL,
truncate(eventType, 48), truncate(eventType, 48),
loginType == null ? null : loginType.name(),
userId, userId,
truncate(username, 64), truncate(username, 64),
truncate(clientIp, 64), truncate(clientIp, 64),
......
...@@ -52,7 +52,8 @@ public class DbUserDetailsService implements UserDetailsService { ...@@ -52,7 +52,8 @@ public class DbUserDetailsService implements UserDetailsService {
user.getPhone(), user.getPhone(),
user.getEmail(), user.getEmail(),
user.getRole(), 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 { ...@@ -32,10 +32,18 @@ public class SecurityUser implements UserDetails {
private final String email; private final String email;
private final String role; private final String role;
private final boolean enabled; private final boolean enabled;
private final boolean mustChangePassword;
public SecurityUser(Long id, String username, String password, String displayName, public SecurityUser(Long id, String username, String password, String displayName,
String policeNumber, String idCardNumber, String phoneNumber, String policeNumber, String idCardNumber, String phoneNumber,
String email, String role, boolean enabled) { 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.id = id;
this.username = username; this.username = username;
this.password = password; this.password = password;
...@@ -46,6 +54,7 @@ public class SecurityUser implements UserDetails { ...@@ -46,6 +54,7 @@ public class SecurityUser implements UserDetails {
this.email = email; this.email = email;
this.role = role; this.role = role;
this.enabled = enabled; this.enabled = enabled;
this.mustChangePassword = mustChangePassword;
} }
@JsonCreator @JsonCreator
...@@ -57,9 +66,10 @@ public class SecurityUser implements UserDetails { ...@@ -57,9 +66,10 @@ public class SecurityUser implements UserDetails {
@JsonProperty("phoneNumber") String phoneNumber, @JsonProperty("phoneNumber") String phoneNumber,
@JsonProperty("email") String email, @JsonProperty("email") String email,
@JsonProperty("role") String role, @JsonProperty("role") String role,
@JsonProperty("enabled") boolean enabled) { @JsonProperty("enabled") boolean enabled,
@JsonProperty("mustChangePassword") Boolean mustChangePassword) {
this(id, username, null, displayName, policeNumber, idCardNumber, phoneNumber, this(id, username, null, displayName, policeNumber, idCardNumber, phoneNumber,
email, role, enabled); email, role, enabled, Boolean.TRUE.equals(mustChangePassword));
} }
public Long getId() { public Long getId() {
...@@ -90,6 +100,11 @@ public class SecurityUser implements UserDetails { ...@@ -90,6 +100,11 @@ public class SecurityUser implements UserDetails {
return role; return role;
} }
@JsonProperty("mustChangePassword")
public boolean isMustChangePassword() {
return mustChangePassword;
}
@JsonIgnore @JsonIgnore
public List<String> getRoleList() { public List<String> getRoleList() {
return List.of(role); return List.of(role);
......
...@@ -74,10 +74,12 @@ public class DataInitializer implements ApplicationRunner { ...@@ -74,10 +74,12 @@ public class DataInitializer implements ApplicationRunner {
if (createdAdmin) { if (createdAdmin) {
log.warn("================================================================"); log.warn("================================================================");
log.warn("已创建默认管理员:admin / {},请登录后立即修改密码!", properties.getAdminPassword()); log.warn("已创建默认管理员账号 admin,请使用配置的初始密码登录并立即修改密码!");
log.warn("演示用户:demo / Demo@123");
log.warn("================================================================"); log.warn("================================================================");
} }
if (createdDemo) {
log.warn("已创建演示用户账号 demo。");
}
log.info("统一认证发行方:{}", properties.getIssuer()); 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; ...@@ -3,8 +3,17 @@ package com.zhihui.portal.config;
import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext; 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.PortalAccounts;
import com.zhihui.portal.auth.SecurityUser; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
...@@ -29,6 +38,7 @@ import org.springframework.security.oauth2.server.authorization.token.JwtEncodin ...@@ -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.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import java.util.ArrayList; import java.util.ArrayList;
...@@ -49,16 +59,34 @@ public class AuthorizationServerConfig { ...@@ -49,16 +59,34 @@ public class AuthorizationServerConfig {
@Bean @Bean
@Order(1) @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 authorizationServerConfigurer =
OAuth2AuthorizationServerConfigurer.authorizationServer(); OAuth2AuthorizationServerConfigurer.authorizationServer();
ActiveUserFilter activeUserFilter = new ActiveUserFilter(userRepository);
LoginIpBindingFilter loginIpBindingFilter = new LoginIpBindingFilter(
clientInfoResolver, auditLogService, sessionRevocationService,
oauthAuthorizationRevocationService);
AppAuthorizationFilter appAuthorizationFilter =
new AppAuthorizationFilter(appRepository, jdbcTemplate);
http http
.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()) .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
.with(authorizationServerConfigurer, authorizationServer -> .with(authorizationServerConfigurer, authorizationServer ->
authorizationServer.oidc(oidc -> oidc.userInfoEndpoint(userInfo -> authorizationServer.oidc(oidc -> oidc.userInfoEndpoint(userInfo ->
userInfo.userInfoMapper(AuthorizationServerConfig::mapUserInfo)))) userInfo.userInfoMapper(AuthorizationServerConfig::mapUserInfo))))
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.addFilterAfter(loginIpBindingFilter, SecurityContextHolderFilter.class)
.addFilterAfter(activeUserFilter, SecurityContextHolderFilter.class)
.addFilterAfter(appAuthorizationFilter, SecurityContextHolderFilter.class)
.exceptionHandling(exceptions -> exceptions .exceptionHandling(exceptions -> exceptions
.defaultAuthenticationEntryPointFor( .defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"), 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; ...@@ -5,12 +5,21 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import com.zhihui.portal.audit.AuditLogService; 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.AuditLogoutSuccessHandler;
import com.zhihui.portal.security.ClientInfoResolver; import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.LoginAttemptService; import com.zhihui.portal.security.LoginAttemptService;
import com.zhihui.portal.security.LoginFailureHandler; import com.zhihui.portal.security.LoginFailureHandler;
import com.zhihui.portal.security.LoginIpBindingFilter;
import com.zhihui.portal.security.LoginRateLimitFilter; import com.zhihui.portal.security.LoginRateLimitFilter;
import com.zhihui.portal.security.LoginSuccessHandler; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
...@@ -21,6 +30,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; ...@@ -21,6 +30,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 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.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.csrf.CsrfToken;
...@@ -51,25 +61,43 @@ public class SecurityConfig { ...@@ -51,25 +61,43 @@ public class SecurityConfig {
LoginAttemptService loginAttemptService, LoginAttemptService loginAttemptService,
ClientInfoResolver clientInfoResolver, ClientInfoResolver clientInfoResolver,
AuditLogService auditLogService, AuditLogService auditLogService,
PortalSecurityProperties securityProperties) PortalSecurityProperties securityProperties,
SysUserRepository userRepository,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService,
SessionRevocationService sessionRevocationService,
CertificateAuthService certificateAuthService)
throws Exception { throws Exception {
RequestMatcher apiMatcher = PathPatternRequestMatcher.withDefaults().matcher("/api/**"); RequestMatcher apiMatcher = PathPatternRequestMatcher.withDefaults().matcher("/api/**");
LoginRateLimitFilter rateLimitFilter = new LoginRateLimitFilter( LoginRateLimitFilter rateLimitFilter = new LoginRateLimitFilter(
loginAttemptService, clientInfoResolver, auditLogService, securityProperties); loginAttemptService, clientInfoResolver, auditLogService, securityProperties);
LoginSuccessHandler loginSuccessHandler = new LoginSuccessHandler( LoginSuccessHandler loginSuccessHandler = new LoginSuccessHandler(
loginAttemptService, clientInfoResolver, auditLogService); loginAttemptService, clientInfoResolver, auditLogService, userRepository);
LoginFailureHandler loginFailureHandler = new LoginFailureHandler( LoginFailureHandler loginFailureHandler = new LoginFailureHandler(
loginAttemptService, clientInfoResolver, auditLogService); loginAttemptService, clientInfoResolver, auditLogService);
CertificateLoginFailureHandler certificateLoginFailureHandler =
new CertificateLoginFailureHandler(
loginAttemptService, clientInfoResolver, auditLogService);
CertificateAuthenticationFilter certificateAuthenticationFilter =
new CertificateAuthenticationFilter(
certificateAuthService, loginSuccessHandler, certificateLoginFailureHandler,
clientInfoResolver, loginAttemptService, auditLogService);
AuditLogoutSuccessHandler logoutSuccessHandler = new AuditLogoutSuccessHandler( 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 http
.authorizeHttpRequests(authorize -> authorize .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/health", "/actuator/info").permitAll()
.requestMatchers("/actuator/**").hasRole("ADMIN") .requestMatchers("/actuator/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated() .requestMatchers("/api/**").authenticated()
.requestMatchers("/change-password").authenticated()
.anyRequest().permitAll()) .anyRequest().permitAll())
.formLogin(form -> form .formLogin(form -> form
.loginPage("/login") .loginPage("/login")
...@@ -86,7 +114,11 @@ public class SecurityConfig { ...@@ -86,7 +114,11 @@ public class SecurityConfig {
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
.addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class) .addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class)
.addFilterAfter(loginIpBindingFilter, SecurityContextHolderFilter.class)
.addFilterAfter(activeUserFilter, SecurityContextHolderFilter.class)
.addFilterAfter(mustChangePasswordFilter, SecurityContextHolderFilter.class)
.addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(certificateAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.sessionManagement(session -> session .sessionManagement(session -> session
.sessionFixation(fixation -> fixation.migrateSession())) .sessionFixation(fixation -> fixation.migrateSession()))
.exceptionHandling(exceptions -> exceptions .exceptionHandling(exceptions -> exceptions
......
...@@ -11,6 +11,7 @@ import com.zhihui.portal.audit.AuditLogService; ...@@ -11,6 +11,7 @@ import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser; import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.config.ZhihuiProperties; import com.zhihui.portal.config.ZhihuiProperties;
import com.zhihui.portal.security.ClientInfoResolver; import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import com.zhihui.portal.user.SysUser; import com.zhihui.portal.user.SysUser;
import com.zhihui.portal.user.UserService; import com.zhihui.portal.user.UserService;
import com.zhihui.portal.user.dto.UserDtos.UpdateEmailRequest; import com.zhihui.portal.user.dto.UserDtos.UpdateEmailRequest;
...@@ -40,15 +41,18 @@ public class PortalController { ...@@ -40,15 +41,18 @@ public class PortalController {
private final AuditLogService auditLogService; private final AuditLogService auditLogService;
private final ClientInfoResolver clientInfoResolver; private final ClientInfoResolver clientInfoResolver;
private final UserService userService; private final UserService userService;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public PortalController(AppService appService, ZhihuiProperties properties, public PortalController(AppService appService, ZhihuiProperties properties,
AuditLogService auditLogService, ClientInfoResolver clientInfoResolver, AuditLogService auditLogService, ClientInfoResolver clientInfoResolver,
UserService userService) { UserService userService,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.appService = appService; this.appService = appService;
this.properties = properties; this.properties = properties;
this.auditLogService = auditLogService; this.auditLogService = auditLogService;
this.clientInfoResolver = clientInfoResolver; this.clientInfoResolver = clientInfoResolver;
this.userService = userService; this.userService = userService;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
} }
@GetMapping("/me") @GetMapping("/me")
...@@ -113,6 +117,9 @@ public class PortalController { ...@@ -113,6 +117,9 @@ public class PortalController {
auditLogService.recordLogout(user, auditLogService.recordLogout(user,
clientInfoResolver.resolveIp(request), clientInfoResolver.resolveIp(request),
clientInfoResolver.resolveUserAgent(request)); clientInfoResolver.resolveUserAgent(request));
if (user != null) {
oauthAuthorizationRevocationService.revokeByPrincipalName(user.getUsername());
}
new SecurityContextLogoutHandler().logout(request, response, authentication); 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 { ...@@ -14,11 +14,14 @@ public class AuditLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
private final AuditLogService auditLogService; private final AuditLogService auditLogService;
private final ClientInfoResolver clientInfoResolver; private final ClientInfoResolver clientInfoResolver;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public AuditLogoutSuccessHandler(AuditLogService auditLogService, public AuditLogoutSuccessHandler(AuditLogService auditLogService,
ClientInfoResolver clientInfoResolver) { ClientInfoResolver clientInfoResolver,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.auditLogService = auditLogService; this.auditLogService = auditLogService;
this.clientInfoResolver = clientInfoResolver; this.clientInfoResolver = clientInfoResolver;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
setDefaultTargetUrl("/login?logout"); setDefaultTargetUrl("/login?logout");
} }
...@@ -27,6 +30,9 @@ public class AuditLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { ...@@ -27,6 +30,9 @@ public class AuditLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
Authentication authentication) throws IOException, ServletException { Authentication authentication) throws IOException, ServletException {
SecurityUser user = authentication != null SecurityUser user = authentication != null
&& authentication.getPrincipal() instanceof SecurityUser value ? value : null; && authentication.getPrincipal() instanceof SecurityUser value ? value : null;
if (user != null) {
oauthAuthorizationRevocationService.revokeByPrincipalName(user.getUsername());
}
auditLogService.recordLogout(user, auditLogService.recordLogout(user,
clientInfoResolver.resolveIp(request), clientInfoResolver.resolveIp(request),
clientInfoResolver.resolveUserAgent(request)); clientInfoResolver.resolveUserAgent(request));
......
...@@ -36,7 +36,7 @@ public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { ...@@ -36,7 +36,7 @@ public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
String userAgent = clientInfoResolver.resolveUserAgent(request); String userAgent = clientInfoResolver.resolveUserAgent(request);
LoginAttemptService.Decision decision = loginAttemptService.recordFailure(username, clientIp); LoginAttemptService.Decision decision = loginAttemptService.recordFailure(username, clientIp);
auditLogService.recordLoginFailure(username, clientIp, userAgent, auditLogService.recordLoginFailure(username, clientIp, userAgent,
reason(exception), decision.blocked()); reason(exception), decision.blocked(), LoginType.PASSWORD);
if (decision.blocked()) { if (decision.blocked()) {
response.setHeader("Retry-After", String.valueOf(decision.retryAfterSeconds())); 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 { ...@@ -51,7 +51,7 @@ public class LoginRateLimitFilter extends OncePerRequestFilter {
auditLogService.recordLoginBlocked(username, clientIp, auditLogService.recordLoginBlocked(username, clientIp,
clientInfoResolver.resolveUserAgent(request), clientInfoResolver.resolveUserAgent(request),
decision.scope(), decision.retryAfterSeconds()); decision.scope(), decision.retryAfterSeconds(), LoginType.PASSWORD);
response.setHeader("Retry-After", String.valueOf(decision.retryAfterSeconds())); response.setHeader("Retry-After", String.valueOf(decision.retryAfterSeconds()));
response.sendRedirect(request.getContextPath() + "/login?error=locked"); 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; ...@@ -2,26 +2,37 @@ package com.zhihui.portal.security;
import com.zhihui.portal.audit.AuditLogService; import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser; import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.user.SysUserRepository;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.util.StringUtils;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { 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 LoginAttemptService loginAttemptService;
private final ClientInfoResolver clientInfoResolver; private final ClientInfoResolver clientInfoResolver;
private final AuditLogService auditLogService; private final AuditLogService auditLogService;
private final SysUserRepository userRepository;
public LoginSuccessHandler(LoginAttemptService loginAttemptService, public LoginSuccessHandler(LoginAttemptService loginAttemptService,
ClientInfoResolver clientInfoResolver, ClientInfoResolver clientInfoResolver,
AuditLogService auditLogService) { AuditLogService auditLogService,
SysUserRepository userRepository) {
this.loginAttemptService = loginAttemptService; this.loginAttemptService = loginAttemptService;
this.clientInfoResolver = clientInfoResolver; this.clientInfoResolver = clientInfoResolver;
this.auditLogService = auditLogService; this.auditLogService = auditLogService;
this.userRepository = userRepository;
setDefaultTargetUrl("/"); setDefaultTargetUrl("/");
setAlwaysUseDefaultTargetUrl(true); setAlwaysUseDefaultTargetUrl(true);
} }
...@@ -30,11 +41,30 @@ public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { ...@@ -30,11 +41,30 @@ public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws ServletException, IOException { Authentication authentication) throws ServletException, IOException {
String clientIp = clientInfoResolver.resolveIp(request); String clientIp = clientInfoResolver.resolveIp(request);
String boundIp = StringUtils.hasText(clientIp) ? clientIp : "unknown";
loginAttemptService.reset(request.getParameter("username"), clientIp); loginAttemptService.reset(request.getParameter("username"), clientIp);
loginAttemptService.reset(authentication.getName(), clientIp); loginAttemptService.reset(authentication.getName(), clientIp);
SecurityUser user = authentication.getPrincipal() instanceof SecurityUser value ? value : null; 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, 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); 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 { ...@@ -44,6 +44,15 @@ public class SysUser {
@Column(name = "status", nullable = false) @Column(name = "status", nullable = false)
private Integer status = 1; 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) @Column(name = "created_at", insertable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
...@@ -130,6 +139,30 @@ public class SysUser { ...@@ -130,6 +139,30 @@ public class SysUser {
this.status = status; 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() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }
......
...@@ -3,9 +3,13 @@ package com.zhihui.portal.user; ...@@ -3,9 +3,13 @@ package com.zhihui.portal.user;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; 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.jpa.repository.Query;
import org.springframework.data.repository.query.Param; 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; import java.util.Optional;
public interface SysUserRepository extends JpaRepository<SysUser, Long> { public interface SysUserRepository extends JpaRepository<SysUser, Long> {
...@@ -44,4 +48,27 @@ public interface SysUserRepository extends JpaRepository<SysUser, Long> { ...@@ -44,4 +48,27 @@ public interface SysUserRepository extends JpaRepository<SysUser, Long> {
or u.phone like concat('%', :kw, '%') or u.phone like concat('%', :kw, '%')
""") """)
Page<SysUser> search(@Param("kw") String keyword, Pageable pageable); 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; ...@@ -43,7 +43,7 @@ import java.util.stream.Collectors;
public class UserImportService { public class UserImportService {
private static final Logger log = LoggerFactory.getLogger(UserImportService.class); 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 long MAX_FILE_BYTES = 5L * 1024 * 1024;
private static final Map<String, String> HEADER_ALIASES = Map.ofEntries( private static final Map<String, String> HEADER_ALIASES = Map.ofEntries(
...@@ -121,6 +121,7 @@ public class UserImportService { ...@@ -121,6 +121,7 @@ public class UserImportService {
ByteArrayOutputStream output = new ByteArrayOutputStream()) { ByteArrayOutputStream output = new ByteArrayOutputStream()) {
XSSFSheet sheet = workbook.createSheet("用户导入"); XSSFSheet sheet = workbook.createSheet("用户导入");
CellStyle headerStyle = headerStyle(workbook); CellStyle headerStyle = headerStyle(workbook);
CellStyle textStyle = textStyle(workbook);
String[] headers = {"用户名", "密码", "姓名", "警号", "身份证号", "邮箱", "手机号", "角色", "状态"}; String[] headers = {"用户名", "密码", "姓名", "警号", "身份证号", "邮箱", "手机号", "角色", "状态"};
int[] widths = {18, 18, 16, 18, 22, 28, 18, 14, 14}; int[] widths = {18, 18, 16, 18, 22, 28, 18, 14, 14};
Row header = sheet.createRow(0); Row header = sheet.createRow(0);
...@@ -130,6 +131,10 @@ public class UserImportService { ...@@ -130,6 +131,10 @@ public class UserImportService {
cell.setCellStyle(headerStyle); cell.setCellStyle(headerStyle);
sheet.setColumnWidth(i, widths[i] * 256); 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); sheet.createFreezePane(0, 1);
XSSFSheet help = workbook.createSheet("填写说明"); XSSFSheet help = workbook.createSheet("填写说明");
...@@ -138,12 +143,12 @@ public class UserImportService { ...@@ -138,12 +143,12 @@ public class UserImportService {
"2. 用户名:3-32 位字母、数字、下划线、点或中划线。", "2. 用户名:3-32 位字母、数字、下划线、点或中划线。",
"3. 密码:8-64 位;导入后请通知用户尽快修改初始密码。", "3. 密码:8-64 位;导入后请通知用户尽快修改初始密码。",
"4. 姓名:必填,最多 64 个字符。", "4. 姓名:必填,最多 64 个字符。",
"5. 警号:普通用户必填,管理员可留空;填写时须为 1-32 位字母、数字或中划线,且不能重复。", "5. 警号:普通用户必填,管理员可留空;填写时须为 1-32 位字母、数字或中划线,且不能重复;本列已预设为文本格式。",
"6. 身份证号:可选,18 位,末位可为 X,且不能重复;请设置为文本格式。", "6. 身份证号:必填,18 位,末位可为 X,且不能重复;本列已预设为文本格式,请勿改成数值格式。",
"7. 邮箱、手机号:可选;手机号须为 11 位中国大陆手机号,且不能重复。", "7. 邮箱:可选;手机号:必填,须为 11 位中国大陆手机号,且不能重复;手机号列已预设为文本格式。",
"8. 角色:ADMIN/USER 或 管理员/普通用户,留空默认 USER。", "8. 角色:ADMIN/USER 或 管理员/普通用户,留空默认 USER。",
"9. 状态:1/0 或 启用/停用,留空默认启用。", "9. 状态:1/0 或 启用/停用,留空默认启用。",
"10. 单次最多导入 200 个用户;文件不超过 5MB。", "10. 单次最多导入 5000 个用户;文件不超过 5MB。",
"11. 用户名、警号、身份证号或手机号重复/已存在时,该行会失败并返回原因,其他行继续导入。" "11. 用户名、警号、身份证号或手机号重复/已存在时,该行会失败并返回原因,其他行继续导入。"
}; };
for (int i = 0; i < lines.length; i++) { for (int i = 0; i < lines.length; i++) {
...@@ -194,6 +199,12 @@ public class UserImportService { ...@@ -194,6 +199,12 @@ public class UserImportService {
if (!columns.containsKey("policeNumber")) { if (!columns.containsKey("policeNumber")) {
missing.add("警号"); missing.add("警号");
} }
if (!columns.containsKey("idCardNumber")) {
missing.add("身份证号");
}
if (!columns.containsKey("phone")) {
missing.add("手机号");
}
if (!missing.isEmpty()) { if (!missing.isEmpty()) {
throw new BizException("模板缺少必填列:" + String.join("、", missing)); throw new BizException("模板缺少必填列:" + String.join("、", missing));
} }
...@@ -227,6 +238,9 @@ public class UserImportService { ...@@ -227,6 +238,9 @@ public class UserImportService {
try { try {
String username = readCell(row, columns.get("username"), formatter); String username = readCell(row, columns.get("username"), formatter);
usernameForError = username; 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 password = readCell(row, columns.get("password"), formatter);
String displayName = readCell(row, columns.get("displayName"), formatter); String displayName = readCell(row, columns.get("displayName"), formatter);
String policeNumber = readCell(row, columns.get("policeNumber"), formatter); String policeNumber = readCell(row, columns.get("policeNumber"), formatter);
...@@ -249,6 +263,12 @@ public class UserImportService { ...@@ -249,6 +263,12 @@ public class UserImportService {
if (!StringUtils.hasText(policeNumber) && !"ADMIN".equals(role)) { if (!StringUtils.hasText(policeNumber) && !"ADMIN".equals(role)) {
throw new IllegalArgumentException("警号不能为空"); throw new IllegalArgumentException("警号不能为空");
} }
if (!StringUtils.hasText(idCardNumber)) {
throw new IllegalArgumentException("身份证号不能为空");
}
if (!StringUtils.hasText(phone)) {
throw new IllegalArgumentException("手机号不能为空");
}
String normalizedUsername = username.trim(); String normalizedUsername = username.trim();
String usernameKey = normalizedUsername.toLowerCase(Locale.ROOT); String usernameKey = normalizedUsername.toLowerCase(Locale.ROOT);
...@@ -288,7 +308,7 @@ public class UserImportService { ...@@ -288,7 +308,7 @@ public class UserImportService {
} }
seenUsernames.add(usernameKey); seenUsernames.add(usernameKey);
userService.create(request, status); userService.createImported(request, status);
if (policeKey != null) { if (policeKey != null) {
seenPoliceNumbers.add(policeKey); seenPoliceNumbers.add(policeKey);
} }
...@@ -316,8 +336,17 @@ public class UserImportService { ...@@ -316,8 +336,17 @@ public class UserImportService {
List.copyOf(errors), List.copyOf(importedUsernames)); 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) { 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; return null;
} }
Cell cell = row.getCell(column, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL); Cell cell = row.getCell(column, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
...@@ -382,6 +411,12 @@ public class UserImportService { ...@@ -382,6 +411,12 @@ public class UserImportService {
&& bytes[2] == 3 && bytes[3] == 4; && 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) { private static CellStyle headerStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle(); CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont(); Font font = workbook.createFont();
......
...@@ -3,6 +3,8 @@ package com.zhihui.portal.user; ...@@ -3,6 +3,8 @@ package com.zhihui.portal.user;
import com.zhihui.portal.auth.SecurityUser; import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.common.BizException; import com.zhihui.portal.common.BizException;
import com.zhihui.portal.common.PageResponse; 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.CreateUserRequest;
import com.zhihui.portal.user.dto.UserDtos.ResetPasswordRequest; import com.zhihui.portal.user.dto.UserDtos.ResetPasswordRequest;
import com.zhihui.portal.user.dto.UserDtos.UpdateUserRequest; import com.zhihui.portal.user.dto.UserDtos.UpdateUserRequest;
...@@ -20,6 +22,7 @@ import org.springframework.util.StringUtils; ...@@ -20,6 +22,7 @@ import org.springframework.util.StringUtils;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
...@@ -36,12 +39,18 @@ public class UserService { ...@@ -36,12 +39,18 @@ public class UserService {
private final SysUserRepository userRepository; private final SysUserRepository userRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final SessionRevocationService sessionRevocationService;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public UserService(SysUserRepository userRepository, PasswordEncoder passwordEncoder, public UserService(SysUserRepository userRepository, PasswordEncoder passwordEncoder,
JdbcTemplate jdbcTemplate) { JdbcTemplate jdbcTemplate,
SessionRevocationService sessionRevocationService,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.sessionRevocationService = sessionRevocationService;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
} }
public PageResponse<UserResponse> list(String keyword, int page, int size) { public PageResponse<UserResponse> list(String keyword, int page, int size) {
...@@ -64,6 +73,16 @@ public class UserService { ...@@ -64,6 +73,16 @@ public class UserService {
@Transactional @Transactional
public UserResponse create(CreateUserRequest request, Integer status) { 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(); String username = request.username().trim();
if (userRepository.existsByUsername(username)) { if (userRepository.existsByUsername(username)) {
throw new BizException("用户名已存在"); throw new BizException("用户名已存在");
...@@ -95,14 +114,17 @@ public class UserService { ...@@ -95,14 +114,17 @@ public class UserService {
user.setPhone(phone); user.setPhone(phone);
user.setRole(role); user.setRole(role);
user.setStatus(initialStatus); user.setStatus(initialStatus);
user.setMustChangePassword(mustChangePassword);
user.setImported(imported);
return UserResponse.from(userRepository.save(user)); return UserResponse.from(userRepository.save(user));
} }
@Transactional @Transactional
public UserResponse update(Long id, UpdateUserRequest request, SecurityUser currentUser) { public UserResponse update(Long id, UpdateUserRequest request, SecurityUser currentUser) {
SysUser user = find(id); SysUser user = find(id);
boolean editingSelf = currentUser.getId().equals(id); SysUser actor = find(currentUser.getId());
if (editingSelf && !currentUser.getRole().equals(request.role())) { boolean editingSelf = actor.getId().equals(id);
if (editingSelf && !actor.getRole().equals(request.role())) {
throw new BizException("不能修改自己的角色"); throw new BizException("不能修改自己的角色");
} }
if (editingSelf && request.status() != null && request.status() != 1) { if (editingSelf && request.status() != null && request.status() != 1) {
...@@ -111,6 +133,13 @@ public class UserService { ...@@ -111,6 +133,13 @@ public class UserService {
if (request.status() != null && request.status() != 0 && request.status() != 1) { if (request.status() != null && request.status() != 0 && request.status() != 1) {
throw new BizException("状态值不合法"); 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(); String username = request.username() == null ? "" : request.username().trim();
if (!USERNAME_PATTERN.matcher(username).matches()) { if (!USERNAME_PATTERN.matcher(username).matches()) {
throw new BizException("用户名需为 3-32 位字母、数字、下划线、点或中划线"); throw new BizException("用户名需为 3-32 位字母、数字、下划线、点或中划线");
...@@ -155,7 +184,21 @@ public class UserService { ...@@ -155,7 +184,21 @@ public class UserService {
if (request.status() != null) { if (request.status() != null) {
user.setStatus(request.status()); 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) { private static String normalizePoliceNumber(String value, String role) {
...@@ -200,6 +243,28 @@ public class UserService { ...@@ -200,6 +243,28 @@ public class UserService {
return user.getEmail(); 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) { public void verifyAdminPassword(Long userId, String password) {
SysUser user = find(userId); SysUser user = find(userId);
if (!"ADMIN".equals(user.getRole()) if (!"ADMIN".equals(user.getRole())
...@@ -215,6 +280,7 @@ public class UserService { ...@@ -215,6 +280,7 @@ public class UserService {
SysUser user = find(id); SysUser user = find(id);
user.setPassword(passwordEncoder.encode(request.password())); user.setPassword(passwordEncoder.encode(request.password()));
userRepository.save(user); userRepository.save(user);
revokeUserAccess(user.getUsername());
} }
@Transactional @Transactional
...@@ -223,6 +289,7 @@ public class UserService { ...@@ -223,6 +289,7 @@ public class UserService {
throw new BizException("不能删除自己"); throw new BizException("不能删除自己");
} }
SysUser user = find(id); SysUser user = find(id);
revokeUserAccess(user.getUsername());
jdbcTemplate.update("DELETE FROM user_app_selection WHERE user_id = ?", id); jdbcTemplate.update("DELETE FROM user_app_selection WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM user_launcher_order WHERE user_id = ?", id); jdbcTemplate.update("DELETE FROM user_launcher_order WHERE user_id = ?", id);
userRepository.delete(user); userRepository.delete(user);
...@@ -246,6 +313,7 @@ public class UserService { ...@@ -246,6 +313,7 @@ public class UserService {
if (users.size() != distinct.size()) { if (users.size() != distinct.size()) {
throw new BizException("部分用户不存在,请刷新后重试"); throw new BizException("部分用户不存在,请刷新后重试");
} }
users.forEach(user -> revokeUserAccess(user.getUsername()));
List<Object[]> rows = distinct.stream().map(id -> new Object[]{id}).toList(); 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_app_selection WHERE user_id = ?", rows);
jdbcTemplate.batchUpdate("DELETE FROM user_launcher_order WHERE user_id = ?", rows); jdbcTemplate.batchUpdate("DELETE FROM user_launcher_order WHERE user_id = ?", rows);
...@@ -253,6 +321,11 @@ public class UserService { ...@@ -253,6 +321,11 @@ public class UserService {
return users.size(); return users.size();
} }
private void revokeUserAccess(String username) {
sessionRevocationService.revokeByUsername(username);
oauthAuthorizationRevocationService.revokeByPrincipalName(username);
}
public List<SysUser> findUsers(List<Long> ids) { public List<SysUser> findUsers(List<Long> ids) {
if (ids == null || ids.isEmpty()) { if (ids == null || ids.isEmpty()) {
return List.of(); return List.of();
......
package com.zhihui.portal.web; package com.zhihui.portal.web;
import com.zhihui.portal.cert.CertificateAuthSettings;
import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@Controller @Controller
public class PageController { public class PageController {
private final CertificateAuthSettings certificateAuthSettings;
public PageController(CertificateAuthSettings certificateAuthSettings) {
this.certificateAuthSettings = certificateAuthSettings;
}
@GetMapping("/") @GetMapping("/")
public String index() { public String index() {
return "redirect:/spa/"; return "redirect:/spa/";
} }
@GetMapping("/login") @GetMapping("/login")
public String login(Authentication authentication) { public String login(Authentication authentication, Model model) {
if (authentication != null if (authentication != null
&& authentication.isAuthenticated() && authentication.isAuthenticated()
&& !(authentication instanceof AnonymousAuthenticationToken)) { && !(authentication instanceof AnonymousAuthenticationToken)) {
return "redirect:/"; return "redirect:/";
} }
model.addAttribute("certAuthEnabled", certificateAuthSettings.isClientReady());
return "login"; 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: server:
port: ${SERVER_PORT:8080} port: ${SERVER_PORT:8080}
forward-headers-strategy: framework # 默认不信任代理头;仅在 zhihui.security.trust-forwarded-headers=true 时注册 ForwardedHeaderFilter
forward-headers-strategy: none
servlet: servlet:
session: session:
timeout: 8h timeout: 8h
...@@ -48,6 +49,7 @@ spring: ...@@ -48,6 +49,7 @@ spring:
redis: redis:
namespace: zhihui:session namespace: zhihui:session
flush-mode: on_save flush-mode: on_save
repository-type: indexed
thymeleaf: thymeleaf:
cache: false cache: false
jackson: jackson:
...@@ -73,6 +75,27 @@ zhihui: ...@@ -73,6 +75,27 @@ zhihui:
max-failures-per-user-ip: ${ZHIHUI_LOGIN_MAX_FAILURES_PER_USER_IP:5} max-failures-per-user-ip: ${ZHIHUI_LOGIN_MAX_FAILURES_PER_USER_IP:5}
max-failures-per-ip: ${ZHIHUI_LOGIN_MAX_FAILURES_PER_IP:20} max-failures-per-ip: ${ZHIHUI_LOGIN_MAX_FAILURES_PER_IP:20}
window: ${ZHIHUI_LOGIN_FAILURE_WINDOW:15m} 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: demo:
enabled: ${ZHIHUI_DEMO_ENABLED:true} enabled: ${ZHIHUI_DEMO_ENABLED:true}
app-url: ${ZHIHUI_DEMO_APP_URL:http://localhost:8081} 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 @@ ...@@ -22,6 +22,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
[hidden] {
display: none !important;
}
html, html,
body { body {
margin: 0; margin: 0;
...@@ -52,9 +56,11 @@ body { ...@@ -52,9 +56,11 @@ body {
z-index: 1; z-index: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; align-items: center;
padding: 48px 56px 44px; justify-content: center;
padding: clamp(36px, 3.5vw, 64px) clamp(40px, 4.2vw, 80px) clamp(34px, 3vw, 56px);
color: #f2f7fc; color: #f2f7fc;
text-align: center;
overflow: hidden; overflow: hidden;
} }
...@@ -70,44 +76,69 @@ body { ...@@ -70,44 +76,69 @@ body {
pointer-events: none; pointer-events: none;
} }
.brand-top { .brand-hero {
position: relative; position: relative;
z-index: 2;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 18px; max-width: 720px;
z-index: 1;
} }
.brand-mark { .brand-mark {
width: 60px;
height: 60px;
display: block; display: block;
width: clamp(150px, 16vw, 280px);
height: auto;
object-fit: contain; 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 { .brand-name {
margin-top: clamp(20px, 3vh, 36px);
font-family: var(--display); font-family: var(--display);
font-size: 28px; font-size: clamp(38px, 4vw, 68px);
letter-spacing: 0.12em; font-weight: 600;
line-height: 1.2; letter-spacing: 0.14em;
line-height: 1.1;
color: #f2f7fc;
text-shadow: 0 0 28px rgba(120, 190, 240, 0.24);
} }
.brand-sub { .brand-sub {
margin-top: 5px; margin-top: clamp(10px, 1.2vh, 16px);
font-size: 13px; 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; 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 { .brand-nodes {
position: absolute; position: absolute;
top: 100px; inset: 0;
left: 50%; z-index: 0;
display: block; display: block;
width: min(760px, 92%, calc(100svh - 200px)); width: 100%;
height: calc(50svh + 136px); height: 100%;
transform: translateX(calc(-50% + 50px)); opacity: 0.38;
opacity: 0.8;
pointer-events: none; pointer-events: none;
filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.35)); filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.35));
} }
...@@ -120,14 +151,14 @@ body { ...@@ -120,14 +151,14 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding: 48px clamp(32px, 6vw, 96px); padding: clamp(40px, 4.5vw, 80px) clamp(32px, 6vw, 96px);
} }
.form-wrap { .form-wrap {
width: 100%; width: 100%;
max-width: 430px; max-width: clamp(430px, 30vw, 560px);
margin: 0 auto; 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%); 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: 1px solid rgba(169, 201, 232, 0.24);
border-top: 2px solid rgba(169, 201, 232, 0.62); border-top: 2px solid rgba(169, 201, 232, 0.62);
...@@ -138,9 +169,9 @@ body { ...@@ -138,9 +169,9 @@ body {
} }
.form-wrap h2 { .form-wrap h2 {
margin: 0 0 28px; margin: 0 0 clamp(22px, 1.8vw, 32px);
font-family: var(--display); font-family: var(--display);
font-size: 34px; font-size: clamp(32px, 2.4vw, 42px);
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: #f2f7fc; color: #f2f7fc;
...@@ -161,23 +192,53 @@ body { ...@@ -161,23 +192,53 @@ body {
border-color: #efd3d3; 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 { .login-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.login-form label { .login-form label {
margin: 0 0 8px; margin: 0 0 clamp(7px, 0.7vw, 10px);
font-size: 13px; font-size: clamp(13px, 1vw, 15px);
color: rgba(220, 231, 243, 0.86); color: rgba(220, 231, 243, 0.86);
} }
.login-form input:not([type="hidden"]) { .login-form input:not([type="hidden"]) {
height: 46px; height: clamp(46px, 3.3vw, 58px);
margin-bottom: 20px; margin-bottom: clamp(18px, 1.4vw, 26px);
padding: 0 14px; padding: 0 clamp(12px, 1.1vw, 18px);
font: inherit; font: inherit;
font-size: 14px; font-size: clamp(14px, 1.05vw, 17px);
color: var(--text); color: var(--text);
background: #fff; background: #fff;
border: 1px solid var(--line); border: 1px solid var(--line);
...@@ -188,16 +249,16 @@ body { ...@@ -188,16 +249,16 @@ body {
.field-control { .field-control {
position: relative; position: relative;
margin-bottom: 20px; margin-bottom: clamp(18px, 1.4vw, 26px);
} }
.field-icon { .field-icon {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 14px; left: clamp(12px, 1vw, 18px);
z-index: 1; z-index: 1;
width: 18px; width: clamp(18px, 1.35vw, 22px);
height: 18px; height: clamp(18px, 1.35vw, 22px);
color: #5b7189; color: #5b7189;
transform: translateY(-50%); transform: translateY(-50%);
pointer-events: none; pointer-events: none;
...@@ -206,7 +267,7 @@ body { ...@@ -206,7 +267,7 @@ body {
.login-form .field-control input:not([type="hidden"]) { .login-form .field-control input:not([type="hidden"]) {
width: 100%; width: 100%;
margin-bottom: 0; margin-bottom: 0;
padding-left: 44px; padding-left: clamp(40px, 3vw, 54px);
} }
.field-control:focus-within .field-icon { .field-control:focus-within .field-icon {
...@@ -260,11 +321,11 @@ body { ...@@ -260,11 +321,11 @@ body {
.login-form button { .login-form button {
align-self: center; align-self: center;
width: min(200px, 100%); width: min(clamp(200px, 14vw, 260px), 100%);
height: 48px; height: clamp(46px, 3.3vw, 56px);
margin-top: 10px; margin-top: clamp(8px, 0.8vw, 14px);
font: inherit; font: inherit;
font-size: 15px; font-size: clamp(15px, 1.1vw, 17px);
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: #fff; color: #fff;
background: var(--primary); background: var(--primary);
...@@ -287,14 +348,153 @@ body { ...@@ -287,14 +348,153 @@ body {
outline-offset: 3px; 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 { .form-foot {
position: absolute; position: absolute;
right: 0; right: 0;
bottom: 22px; bottom: clamp(18px, 1.6vh, 28px);
left: 0; left: 0;
z-index: 2; z-index: 2;
text-align: center; text-align: center;
font-size: 17px; font-size: clamp(16px, 1.18vw, 20px);
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: rgba(220, 231, 243, 0.76); color: rgba(220, 231, 243, 0.76);
} }
...@@ -305,7 +505,7 @@ body { ...@@ -305,7 +505,7 @@ body {
.form-foot span + span { .form-foot span + span {
margin-top: 4px; margin-top: 4px;
font-size: 14px; font-size: clamp(13px, 0.98vw, 17px);
letter-spacing: 0.12em; letter-spacing: 0.12em;
color: rgba(220, 231, 243, 0.56); color: rgba(220, 231, 243, 0.56);
} }
...@@ -320,6 +520,32 @@ body { ...@@ -320,6 +520,32 @@ body {
padding: 24px 24px 20px; 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 { .brand-nodes {
display: none; 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 @@ ...@@ -7,6 +7,7 @@
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)') const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
const timeOrigin = performance.now()
const W = 1040 const W = 1040
const H = 760 const H = 760
...@@ -18,32 +19,34 @@ ...@@ -18,32 +19,34 @@
{ weight: 0.5, x1: 20, x2: 1020, y1: 14, y2: 180 }, { 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: 10, x2: 180, y1: 140, y2: 480 },
{ weight: 0.17, x1: 860, x2: 1030, 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: 60, x2: 260, y1: 380, y2: 500 },
{ weight: 0.08, x1: 780, x2: 980, y1: 420, y2: 560 } { weight: 0.08, x1: 780, x2: 980, y1: 380, y2: 500 }
] ]
function randomBetween(min, max) { function randomBetween(min, max) {
return min + Math.random() * (max - min) return min + Math.random() * (max - min)
} }
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value))
}
function randomStart() { function randomStart() {
let roll = Math.random() for (let attempt = 0; attempt < 12; attempt++) {
const zone = ZONES.find((item) => (roll -= item.weight) <= 0) || ZONES[0] let roll = Math.random()
return { const zone = ZONES.find((item) => (roll -= item.weight) <= 0) || ZONES[0]
x: randomBetween(zone.x1, zone.x2), const start = {
y: randomBetween(zone.y1, zone.y2) 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() { function randomGeometry() {
const start = randomStart() const start = randomStart()
const end = { const end = {
x: GATE.x + clamp((start.x - GATE.x) * 0.35 + randomBetween(-60, 60), -240, 240), x: GATE.x + randomBetween(-100, 100),
y: GATE.y - 30 + randomBetween(-24, 24) y: GATE.y
} }
const dx = start.x - GATE.x const dx = start.x - GATE.x
const dy = start.y - GATE.y const dy = start.y - GATE.y
...@@ -60,11 +63,11 @@ ...@@ -60,11 +63,11 @@
} }
function makeParticle() { function makeParticle() {
const duration = randomBetween(7, 14) const duration = randomBetween(8.75, 17.5)
return { return {
...randomGeometry(), ...randomGeometry(),
duration, duration,
startedAt: -Math.random() * duration, startedAt: -Math.random() * duration * 0.65,
active: true, active: true,
nextAt: 0 nextAt: 0
} }
...@@ -159,7 +162,7 @@ ...@@ -159,7 +162,7 @@
function recycleParticle(particle, t) { function recycleParticle(particle, t) {
Object.assign(particle, randomGeometry()) Object.assign(particle, randomGeometry())
particle.duration = randomBetween(7, 14) particle.duration = randomBetween(8.75, 17.5)
particle.startedAt = t particle.startedAt = t
particle.active = true particle.active = true
} }
...@@ -168,7 +171,7 @@ ...@@ -168,7 +171,7 @@
const point = screenPoint(quadPoint(particle, progress)) const point = screenPoint(quadPoint(particle, progress))
const depth = 0.22 + progress * 0.58 const depth = 0.22 + progress * 0.58
const fadeIn = Math.min(1, progress / 0.05) 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 { return {
x: point.x, x: point.x,
y: point.y, y: point.y,
...@@ -193,9 +196,7 @@ ...@@ -193,9 +196,7 @@
drawGate() drawGate()
const t = time / 1000 const t = Math.max(0, time - timeOrigin) / 1000
const minDistance = 22 * (width / 700)
const dots = []
for (const particle of particles) { for (const particle of particles) {
if (!particle.active) { if (!particle.active) {
...@@ -223,11 +224,6 @@ ...@@ -223,11 +224,6 @@
continue continue
} }
if (dots.some((item) => Math.hypot(item.x - dot.x, item.y - dot.y) < minDistance)) {
continue
}
dots.push(dot)
drawDot(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 @@ ...@@ -4,6 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 · 智汇平台</title> <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="stylesheet" th:href="@{/css/login.css}">
<link rel="icon" th:href="@{/img/badge.png}" type="image/png"> <link rel="icon" th:href="@{/img/badge.png}" type="image/png">
</head> </head>
...@@ -11,53 +14,110 @@ ...@@ -11,53 +14,110 @@
<main class="login-shell"> <main class="login-shell">
<div class="brand-grid" aria-hidden="true"></div> <div class="brand-grid" aria-hidden="true"></div>
<section class="brand-panel"> <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"> <img th:src="@{/img/badge.png}" alt="" class="brand-mark">
<div> <div class="brand-name">智汇平台</div>
<div class="brand-name">智汇平台</div> <div class="brand-sub">统一身份认证平台</div>
<div class="brand-sub">统一身份认证平台</div> <div class="brand-slogan">
<i aria-hidden="true"></i>
<span>统一身份 · 安全互联</span>
<i aria-hidden="true"></i>
</div> </div>
</div> </div>
<canvas class="brand-nodes" width="1040" height="640" aria-hidden="true"></canvas>
</section> </section>
<section class="form-panel"> <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> <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"> <div class="alert alert-error" th:if="${param.error}" role="alert">
<span th:if="${param.error[0] == 'locked'}">登录尝试过于频繁,请稍后再试。</span> <span th:switch="${param.error[0]}">
<span th:unless="${param.error[0] == 'locked'}">账号或密码错误,或账号已被停用。</span> <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>
<form class="login-form" th:action="@{/login}" method="post"> <div class="login-tabs" role="tablist" aria-label="登录方式" th:if="${certAuthEnabled}">
<label for="username">账号</label> <button type="button" class="login-tab" role="tab"
<div class="field-control"> data-login-tab="account" aria-controls="account-panel"
<svg class="field-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" th:classappend="${certTab} ? '' : 'is-active'"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> th:attr="aria-selected=${!certTab}">账号登录</button>
<circle cx="12" cy="8" r="3.5"/> <button type="button" class="login-tab" role="tab"
<path d="M5 20c.8-3.5 3.5-5.5 7-5.5s6.2 2 7 5.5"/> data-login-tab="cert" aria-controls="cert-panel"
</svg> th:classappend="${certTab} ? 'is-active' : ''"
<input id="username" name="username" type="text" autocomplete="username" th:attr="aria-selected=${certTab}">证书登录</button>
placeholder="请输入警号、身份证号或手机号" required autofocus> </div>
</div>
<label for="password">密码</label> <div id="account-panel" class="login-tab-panel" role="tabpanel"
<div class="field-control"> data-login-panel="account" th:hidden="${certTab}">
<svg class="field-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" <form class="login-form" th:action="@{/login}" method="post">
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <label for="username">账号</label>
<rect x="5" y="11" width="14" height="9" rx="2"/> <div class="field-control">
<path d="M8 11V8a4 4 0 0 1 8 0v3"/> <svg class="field-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<path d="M12 15v2"/> stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
</svg> <circle cx="12" cy="8" r="3.5"/>
<input id="password" name="password" type="password" autocomplete="current-password" <path d="M5 20c.8-3.5 3.5-5.5 7-5.5s6.2 2 7 5.5"/>
placeholder="请输入密码" required> </svg>
<input id="username" name="username" type="text" autocomplete="username"
placeholder="请输入警号、身份证号或手机号" required autofocus>
</div>
<label for="password">密码</label>
<div class="field-control">
<svg class="field-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="5" y="11" width="14" height="9" rx="2"/>
<path d="M8 11V8a4 4 0 0 1 8 0v3"/>
<path d="M12 15v2"/>
</svg>
<input id="password" name="password" type="password" autocomplete="current-password"
placeholder="请输入密码" required>
</div>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<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>
</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}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button type="submit">登录</button>
</form> </form>
</div> </div>
</section> </section>
...@@ -67,5 +127,7 @@ ...@@ -67,5 +127,7 @@
</footer> </footer>
</main> </main>
<script th:src="@{/js/login-network-3d.js}"></script> <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> </body>
</html> </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; ...@@ -2,12 +2,15 @@ package com.zhihui.portal.user;
import com.zhihui.portal.common.BizException; import com.zhihui.portal.common.BizException;
import com.zhihui.portal.auth.SecurityUser; 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.CreateUserRequest;
import com.zhihui.portal.user.dto.UserDtos.UpdateUserRequest; import com.zhihui.portal.user.dto.UserDtos.UpdateUserRequest;
import com.zhihui.portal.user.dto.UserDtos.UserResponse; import com.zhihui.portal.user.dto.UserDtos.UserResponse;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
...@@ -16,8 +19,10 @@ import org.springframework.security.crypto.password.PasswordEncoder; ...@@ -16,8 +19,10 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows; 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.any;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
...@@ -36,11 +41,18 @@ class UserServiceTest { ...@@ -36,11 +41,18 @@ class UserServiceTest {
@Mock @Mock
private JdbcTemplate jdbcTemplate; private JdbcTemplate jdbcTemplate;
@Mock
private SessionRevocationService sessionRevocationService;
@Mock
private OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
private UserService userService; private UserService userService;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
userService = new UserService(userRepository, passwordEncoder, jdbcTemplate); userService = new UserService(userRepository, passwordEncoder, jdbcTemplate,
sessionRevocationService, oauthAuthorizationRevocationService);
} }
@Test @Test
...@@ -185,6 +197,7 @@ class UserServiceTest { ...@@ -185,6 +197,7 @@ class UserServiceTest {
1L, "admin", "encoded", "系统管理员", null, null, null, null, "ADMIN", true); 1L, "admin", "encoded", "系统管理员", null, null, null, null, "ADMIN", true);
when(userRepository.findById(2L)).thenReturn(Optional.of(target)); 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.existsByUsernameAndIdNot("test-renamed", 2L)).thenReturn(false);
when(userRepository.save(any(SysUser.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(userRepository.save(any(SysUser.class))).thenAnswer(invocation -> invocation.getArgument(0));
...@@ -226,6 +239,7 @@ class UserServiceTest { ...@@ -226,6 +239,7 @@ class UserServiceTest {
1L, "admin", "encoded", "系统管理员", null, null, null, null, "ADMIN", true); 1L, "admin", "encoded", "系统管理员", null, null, null, null, "ADMIN", true);
when(userRepository.findById(2L)).thenReturn(Optional.of(target)); when(userRepository.findById(2L)).thenReturn(Optional.of(target));
when(userRepository.findById(1L)).thenReturn(Optional.of(adminActor(admin)));
BizException error = assertThrows(BizException.class, () -> userService.update( BizException error = assertThrows(BizException.class, () -> userService.update(
2L, new UpdateUserRequest("admin", "测试用户", "FJ001", null, null, null, "USER", 1), admin)); 2L, new UpdateUserRequest("admin", "测试用户", "FJ001", null, null, null, "USER", 1), admin));
...@@ -246,6 +260,7 @@ class UserServiceTest { ...@@ -246,6 +260,7 @@ class UserServiceTest {
1L, "admin", "encoded", "系统管理员", null, null, null, null, "ADMIN", true); 1L, "admin", "encoded", "系统管理员", null, null, null, null, "ADMIN", true);
when(userRepository.findById(2L)).thenReturn(Optional.of(target)); when(userRepository.findById(2L)).thenReturn(Optional.of(target));
when(userRepository.findById(1L)).thenReturn(Optional.of(adminActor(admin)));
when(userRepository.existsByUsernameAndIdNot("taken", 2L)).thenReturn(true); when(userRepository.existsByUsernameAndIdNot("taken", 2L)).thenReturn(true);
BizException error = assertThrows(BizException.class, () -> userService.update( BizException error = assertThrows(BizException.class, () -> userService.update(
...@@ -253,4 +268,67 @@ class UserServiceTest { ...@@ -253,4 +268,67 @@ class UserServiceTest {
assertEquals("用户名已存在", error.getMessage()); assertEquals("用户名已存在", error.getMessage());
verify(userRepository, never()).save(any(SysUser.class)); 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 { ...@@ -50,6 +50,8 @@ class OidcDiscovery {
required(json, "authorization_endpoint"), required(json, "authorization_endpoint"),
required(json, "token_endpoint"), required(json, "token_endpoint"),
required(json, "jwks_uri"), required(json, "jwks_uri"),
text(json, "userinfo_endpoint",
trimTrailingSlash(properties.getIssuer()) + "/userinfo"),
text(json, "end_session_endpoint", null)); text(json, "end_session_endpoint", null));
this.metadata = loaded; this.metadata = loaded;
return loaded; return loaded;
...@@ -84,6 +86,6 @@ class OidcDiscovery { ...@@ -84,6 +86,6 @@ class OidcDiscovery {
} }
record Metadata(String issuer, String authorizationEndpoint, String tokenEndpoint, 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 { ...@@ -93,6 +93,9 @@ public class SsoAuthenticationFilter extends OncePerRequestFilter {
SsoUser user = SsoSession.getUser(request); SsoUser user = SsoSession.getUser(request);
if (user != null) { if (user != null) {
if (!validateSession(request, response)) {
return;
}
if (path.equals(loginPath())) { if (path.equals(loginPath())) {
redirectAfterLogin(request, response); redirectAfterLogin(request, response);
return; return;
...@@ -220,6 +223,10 @@ public class SsoAuthenticationFilter extends OncePerRequestFilter { ...@@ -220,6 +223,10 @@ public class SsoAuthenticationFilter extends OncePerRequestFilter {
if (StringUtils.hasText(tokens.accessToken())) { if (StringUtils.hasText(tokens.accessToken())) {
session.setAttribute(SsoSession.ACCESS_TOKEN_ATTRIBUTE, 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()); log.info("统一认证登录成功: user={}, sub={}", user.getUsername(), user.getSubject());
String target = safeLocalPath(savedRequest); String target = safeLocalPath(savedRequest);
...@@ -227,6 +234,161 @@ public class SsoAuthenticationFilter extends OncePerRequestFilter { ...@@ -227,6 +234,161 @@ public class SsoAuthenticationFilter extends OncePerRequestFilter {
request.getContextPath() + (target != null ? target : "/"))); 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 { private void handleLogout(HttpServletRequest request, HttpServletResponse response) throws IOException {
HttpSession session = request.getSession(false); HttpSession session = request.getSession(false);
String idToken = null; String idToken = null;
...@@ -303,7 +465,8 @@ public class SsoAuthenticationFilter extends OncePerRequestFilter { ...@@ -303,7 +465,8 @@ public class SsoAuthenticationFilter extends OncePerRequestFilter {
if (!StringUtils.hasText(idToken)) { if (!StringUtils.hasText(idToken)) {
throw new SsoException("统一认证未返回 ID Token"); 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) { } catch (InterruptedException ex) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new SsoException("令牌交换被中断"); throw new SsoException("令牌交换被中断");
...@@ -545,6 +708,6 @@ public class SsoAuthenticationFilter extends OncePerRequestFilter { ...@@ -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 { ...@@ -36,6 +36,9 @@ public class SsoProperties {
private Duration readTimeout = Duration.ofSeconds(10); private Duration readTimeout = Duration.ofSeconds(10);
/** 已登录会话通过 UserInfo 重新校验的间隔;设为 0 可关闭 */
private Duration sessionRevalidateInterval = Duration.ofSeconds(60);
private String loginPath = "/sso/login"; private String loginPath = "/sso/login";
private String callbackPath = "/sso/callback"; private String callbackPath = "/sso/callback";
...@@ -122,6 +125,14 @@ public class SsoProperties { ...@@ -122,6 +125,14 @@ public class SsoProperties {
this.readTimeout = readTimeout; this.readTimeout = readTimeout;
} }
public Duration getSessionRevalidateInterval() {
return sessionRevalidateInterval;
}
public void setSessionRevalidateInterval(Duration sessionRevalidateInterval) {
this.sessionRevalidateInterval = sessionRevalidateInterval;
}
public String getLoginPath() { public String getLoginPath() {
return loginPath; return loginPath;
} }
......
...@@ -8,6 +8,8 @@ public final class SsoSession { ...@@ -8,6 +8,8 @@ public final class SsoSession {
public static final String USER_ATTRIBUTE = "ZHIHUI_SSO_USER"; public static final String USER_ATTRIBUTE = "ZHIHUI_SSO_USER";
public static final String ACCESS_TOKEN_ATTRIBUTE = "ZHIHUI_SSO_ACCESS_TOKEN"; 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 ID_TOKEN_ATTRIBUTE = "ZHIHUI_SSO_ID_TOKEN";
static final String STATE_ATTRIBUTE = "ZHIHUI_SSO_STATE"; static final String STATE_ATTRIBUTE = "ZHIHUI_SSO_STATE";
static final String NONCE_ATTRIBUTE = "ZHIHUI_SSO_NONCE"; static final String NONCE_ATTRIBUTE = "ZHIHUI_SSO_NONCE";
......
...@@ -13,7 +13,8 @@ http.interceptors.response.use( ...@@ -13,7 +13,8 @@ http.interceptors.response.use(
(error) => { (error) => {
if (axios.isAxiosError(error) && error.response?.status === 401) { if (axios.isAxiosError(error) && error.response?.status === 401) {
if (!window.location.pathname.startsWith('/login')) { 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) return Promise.reject(error)
......
...@@ -105,12 +105,14 @@ export interface PageResult<T> { ...@@ -105,12 +105,14 @@ export interface PageResult<T> {
export interface AuditLogEntry { export interface AuditLogEntry {
id: number id: number
eventType: string eventType: string
loginType: string | null
userId: number | null userId: number | null
username: string | null username: string | null
clientIp: string | null clientIp: string | null
userAgent: string | null userAgent: string | null
targetType: string | null targetType: string | null
targetId: string | null targetId: string | null
targetName: string | null
detail: string | null detail: string | null
success: boolean success: boolean
createdAt: string createdAt: string
...@@ -120,6 +122,7 @@ export interface LoginRecord { ...@@ -120,6 +122,7 @@ export interface LoginRecord {
id: number id: number
clientIp: string | null clientIp: string | null
userAgent: string | null userAgent: string | null
loginType: string | null
createdAt: string createdAt: string
} }
...@@ -204,7 +207,7 @@ export const adminApi = { ...@@ -204,7 +207,7 @@ export const adminApi = {
importUsers: (file: File) => { importUsers: (file: File) => {
const form = new FormData() const form = new FormData()
form.append('file', file) 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: () => downloadUserTemplate: () =>
http.get<Blob>('/api/admin/users/import-template', { responseType: 'blob' }), 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 @@ ...@@ -2,7 +2,7 @@
<el-dialog <el-dialog
v-model="visible" v-model="visible"
class="portal-dark-dialog" class="portal-dark-dialog"
title="应用市场" title="智能体广场"
width="min(760px, 94vw)" width="min(760px, 94vw)"
destroy-on-close destroy-on-close
@open="load" @open="load"
...@@ -54,8 +54,8 @@ ...@@ -54,8 +54,8 @@
</div> </div>
<div v-else-if="!loading" class="market-empty"> <div v-else-if="!loading" class="market-empty">
<p>{{ keyword.trim() ? '没有匹配的应用' : '应用市场还没有上架应用' }}</p> <p>{{ keyword.trim() ? '没有匹配的应用' : '智能体广场还没有上架应用' }}</p>
<span v-if="!keyword.trim()">请联系管理员在“市场管理”中接入子系统。</span> <span v-if="!keyword.trim()">请联系管理员在“智能体广场管理”中接入子系统。</span>
</div> </div>
</div> </div>
......
<template> <template>
<div class="app-item" :class="{ 'is-managing': manage }" @contextmenu="handleContextMenu"> <div class="app-item" @contextmenu="handleContextMenu">
<component <a
:is="linkable ? 'a' : 'div'"
class="app-link" class="app-link"
:href="linkable ? app.appUrl : undefined" :href="app.appUrl"
:target="linkable ? '_blank' : undefined" target="_blank"
:rel="linkable ? 'noopener' : undefined" rel="noopener"
:title="linkable ? app.description || app.appName : undefined" :title="app.description || app.appName"
:draggable="linkable ? 'false' : undefined" draggable="false"
> >
<span class="app-icon" :style="{ background: tone.bg, color: tone.fg }"> <span class="app-icon" :style="{ background: tone.bg, color: tone.fg }">
<img v-if="app.iconUrl" :src="app.iconUrl" alt="" /> <img v-if="app.iconUrl" :src="app.iconUrl" alt="" />
<span v-else>{{ initials }}</span> <span v-else>{{ initials }}</span>
</span> </span>
<span class="app-name">{{ app.appName }}</span> <span class="app-name">{{ app.appName }}</span>
</component> </a>
<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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { EditPen, Remove } from '@element-plus/icons-vue'
import type { PortalApp } from '@/api/portal' import type { PortalApp } from '@/api/portal'
import { appInitials, appTone } from '@/utils/appTone' import { appInitials, appTone } from '@/utils/appTone'
const props = withDefaults( const props = defineProps<{ app: PortalApp }>()
defineProps<{ const emit = defineEmits<{ menu: [app: PortalApp, event: MouseEvent] }>()
app: PortalApp
manage?: boolean
linkable?: boolean
}>(),
{ manage: false, linkable: true },
)
const emit = defineEmits<{ edit: []; remove: []; menu: [app: PortalApp, event: MouseEvent] }>()
function handleContextMenu(event: MouseEvent) { function handleContextMenu(event: MouseEvent) {
if (props.manage) {
return
}
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
emit('menu', props.app, event) emit('menu', props.app, event)
...@@ -60,9 +38,9 @@ const initials = computed(() => appInitials(props.app.appName)) ...@@ -60,9 +38,9 @@ const initials = computed(() => appInitials(props.app.appName))
<style scoped> <style scoped>
.app-item { .app-item {
position: relative; position: relative;
width: 112px; width: clamp(104px, 7.4vw, 140px);
flex: none; 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; border-radius: 12px;
user-select: none; user-select: none;
-webkit-user-drag: none; -webkit-user-drag: none;
...@@ -73,19 +51,11 @@ const initials = computed(() => appInitials(props.app.appName)) ...@@ -73,19 +51,11 @@ const initials = computed(() => appInitials(props.app.appName))
transform: translateY(-3px); transform: translateY(-3px);
} }
.app-item.is-managing {
cursor: default;
}
.app-item.is-managing:hover {
transform: none;
}
.app-link { .app-link {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 12px; gap: clamp(10px, 0.9vw, 16px);
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
...@@ -94,14 +64,14 @@ const initials = computed(() => appInitials(props.app.appName)) ...@@ -94,14 +64,14 @@ const initials = computed(() => appInitials(props.app.appName))
position: relative; position: relative;
display: grid; display: grid;
place-items: center; place-items: center;
width: 84px; width: clamp(62px, 4.64vw, 88px);
height: 84px; height: clamp(62px, 4.64vw, 88px);
overflow: hidden; overflow: hidden;
font-family: var(--display); font-family: var(--display);
font-size: 28px; font-size: clamp(21px, 1.52vw, 28px);
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.04em;
border-radius: 20px; border-radius: clamp(15px, 1.12vw, 21px);
box-shadow: box-shadow:
0 12px 28px rgba(0, 0, 0, 0.44), 0 12px 28px rgba(0, 0, 0, 0.44),
0 0 0 1px rgba(169, 201, 232, 0.16); 0 0 0 1px rgba(169, 201, 232, 0.16);
...@@ -124,12 +94,6 @@ const initials = computed(() => appInitials(props.app.appName)) ...@@ -124,12 +94,6 @@ const initials = computed(() => appInitials(props.app.appName))
0 0 0 1px rgba(169, 201, 232, 0.32); 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 { .app-icon img {
width: 100%; width: 100%;
height: 100%; height: 100%;
...@@ -137,9 +101,9 @@ const initials = computed(() => appInitials(props.app.appName)) ...@@ -137,9 +101,9 @@ const initials = computed(() => appInitials(props.app.appName))
} }
.app-name { .app-name {
max-width: 112px; max-width: clamp(104px, 7.4vw, 140px);
overflow: hidden; overflow: hidden;
font-size: 13px; font-size: clamp(12px, 0.9vw, 15px);
line-height: 1.5; line-height: 1.5;
color: rgba(220, 231, 243, 0.82); color: rgba(220, 231, 243, 0.82);
text-align: center; text-align: center;
...@@ -157,42 +121,6 @@ const initials = computed(() => appInitials(props.app.appName)) ...@@ -157,42 +121,6 @@ const initials = computed(() => appInitials(props.app.appName))
outline-offset: 4px; 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) { @media (max-width: 520px) {
.app-item { .app-item {
width: 92px; width: 92px;
......
<template> <template>
<div <div
class="portal" 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> <div v-if="isDarkPortal" class="portal-grid" aria-hidden="true"></div>
<header class="masthead"> <header class="masthead">
...@@ -175,6 +175,11 @@ onBeforeUnmount(() => { ...@@ -175,6 +175,11 @@ onBeforeUnmount(() => {
z-index: 1; z-index: 1;
} }
.portal.is-light-app-center .portal-main {
color: #1f2937;
background: #ffffff;
}
.masthead { .masthead {
position: sticky; position: sticky;
top: 0; top: 0;
...@@ -189,24 +194,24 @@ onBeforeUnmount(() => { ...@@ -189,24 +194,24 @@ onBeforeUnmount(() => {
.masthead-inner { .masthead-inner {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24px; gap: clamp(18px, 1.6vw, 28px);
max-width: 1280px; max-width: clamp(1280px, 80vw, 1800px);
height: 73px; height: clamp(68px, 4.6vw, 92px);
margin: 0 auto; margin: 0 auto;
padding: 0 40px; padding: 0 var(--page-pad-x);
} }
.brand { .brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 15px; gap: clamp(14px, 1.2vw, 22px);
text-decoration: none; text-decoration: none;
} }
.brand-mark { .brand-mark {
display: block; display: block;
width: 48px; width: clamp(53px, 3.6vw, 70px);
height: 48px; height: clamp(53px, 3.6vw, 70px);
object-fit: contain; object-fit: contain;
} }
...@@ -218,14 +223,14 @@ onBeforeUnmount(() => { ...@@ -218,14 +223,14 @@ onBeforeUnmount(() => {
.brand-name { .brand-name {
font-family: var(--display); font-family: var(--display);
font-size: 23px; font-size: clamp(25px, 1.8vw, 32px);
letter-spacing: 0.12em; letter-spacing: 0.12em;
color: #f2f7fc; color: #f2f7fc;
} }
.brand-sub { .brand-sub {
margin-top: 5px; margin-top: 6px;
font-size: 11px; font-size: clamp(12px, 0.94vw, 16px);
letter-spacing: 0.16em; letter-spacing: 0.16em;
color: rgba(220, 231, 243, 0.5); color: rgba(220, 231, 243, 0.5);
} }
...@@ -239,7 +244,7 @@ onBeforeUnmount(() => { ...@@ -239,7 +244,7 @@ onBeforeUnmount(() => {
.mast-nav { .mast-nav {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
gap: 28px; gap: clamp(20px, 1.8vw, 32px);
align-self: stretch; align-self: stretch;
} }
...@@ -247,7 +252,7 @@ onBeforeUnmount(() => { ...@@ -247,7 +252,7 @@ onBeforeUnmount(() => {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 14px; font-size: clamp(13px, 0.95vw, 16px);
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: rgba(220, 231, 243, 0.68); color: rgba(220, 231, 243, 0.68);
text-decoration: none; text-decoration: none;
...@@ -306,10 +311,10 @@ onBeforeUnmount(() => { ...@@ -306,10 +311,10 @@ onBeforeUnmount(() => {
.avatar { .avatar {
display: grid; display: grid;
place-items: center; place-items: center;
width: 32px; width: clamp(30px, 2.2vw, 40px);
height: 32px; height: clamp(30px, 2.2vw, 40px);
font-family: var(--display); font-family: var(--display);
font-size: 14px; font-size: clamp(13px, 0.95vw, 16px);
color: #dce7f3; color: #dce7f3;
background: var(--ink-3); background: var(--ink-3);
border: 1px solid rgba(201, 162, 75, 0.6); border: 1px solid rgba(201, 162, 75, 0.6);
...@@ -324,12 +329,12 @@ onBeforeUnmount(() => { ...@@ -324,12 +329,12 @@ onBeforeUnmount(() => {
} }
.user-name { .user-name {
font-size: 13px; font-size: clamp(13px, 0.9vw, 15px);
color: #f2f7fc; color: #f2f7fc;
} }
.user-role { .user-role {
font-size: 10px; font-size: clamp(10px, 0.72vw, 12px);
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: rgba(220, 231, 243, 0.46); color: rgba(220, 231, 243, 0.46);
} }
...@@ -348,7 +353,7 @@ onBeforeUnmount(() => { ...@@ -348,7 +353,7 @@ onBeforeUnmount(() => {
position: absolute; position: absolute;
top: calc(100% + 10px); top: calc(100% + 10px);
right: 0; right: 0;
width: 224px; width: clamp(216px, 16vw, 260px);
padding: 6px; padding: 6px;
background: var(--paper); background: var(--paper);
border: 1px solid var(--line); border: 1px solid var(--line);
...@@ -423,7 +428,7 @@ onBeforeUnmount(() => { ...@@ -423,7 +428,7 @@ onBeforeUnmount(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 3px; gap: clamp(3px, 0.3vw, 5px);
} }
.portal-foot-copy span + span { .portal-foot-copy span + span {
...@@ -437,7 +442,7 @@ onBeforeUnmount(() => { ...@@ -437,7 +442,7 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 16px; gap: 16px;
padding: 22px 24px; padding: clamp(18px, 1.6vw, 28px) clamp(20px, 2vw, 32px);
font-size: 13px; font-size: 13px;
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: var(--muted); color: var(--muted);
...@@ -502,13 +507,133 @@ onBeforeUnmount(() => { ...@@ -502,13 +507,133 @@ onBeforeUnmount(() => {
.portal.is-dark-portal .portal-foot { .portal.is-dark-portal .portal-foot {
position: relative; position: relative;
z-index: 0; z-index: 0;
font-size: 15px; font-size: clamp(16px, 1.18vw, 20px);
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: rgba(220, 231, 243, 0.58); color: rgba(220, 231, 243, 0.76);
background: transparent; background: transparent;
border-top: 0; 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) { @media (max-height: 820px) {
.portal.is-dark-portal .portal-foot { .portal.is-dark-portal .portal-foot {
padding: 16px 24px; padding: 16px 24px;
......
...@@ -17,6 +17,16 @@ ...@@ -17,6 +17,16 @@
--sans: "Noto Sans SC", "Source Han Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; --sans: "Noto Sans SC", "Source Han Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
--mono: "JetBrains Mono", "Cascadia Code", Consolas, "Courier New", monospace; --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: #11539b;
--el-color-primary-light-3: #4a7cb8; --el-color-primary-light-3: #4a7cb8;
--el-color-primary-light-5: #7fa3cc; --el-color-primary-light-5: #7fa3cc;
...@@ -123,7 +133,7 @@ input { ...@@ -123,7 +133,7 @@ input {
.el-dialog__title { .el-dialog__title {
font-family: var(--display); font-family: var(--display);
font-size: 19px; font-size: clamp(18px, 1.3vw, 22px);
letter-spacing: 0.03em; letter-spacing: 0.03em;
color: var(--ink); color: var(--ink);
} }
...@@ -132,7 +142,7 @@ input { ...@@ -132,7 +142,7 @@ input {
--el-table-border-color: var(--line); --el-table-border-color: var(--line);
--el-table-header-bg-color: #f2f6fb; --el-table-header-bg-color: #f2f6fb;
--el-table-row-hover-bg-color: #f7fafd; --el-table-row-hover-bg-color: #f7fafd;
font-size: 13px; font-size: clamp(12px, 0.85vw, 14px);
} }
.el-table th.el-table__cell { .el-table th.el-table__cell {
......
<template> <template>
<div class="launcher"> <div class="launcher">
<header class="launcher-head" :class="{ 'is-editing': manageMode }"> <header class="launcher-head">
<div class="head-copy">
<h1>应用中心</h1>
<p v-if="manageMode" class="edit-hint">按住任意图标拖动调整顺序,点击“完成”退出管理</p>
</div>
<div class="launcher-tools"> <div class="launcher-tools">
<el-input <el-input
v-if="!manageMode"
v-model="keyword" v-model="keyword"
class="launcher-search" class="launcher-search"
placeholder="搜索应用" placeholder="搜索应用"
clearable clearable
:prefix-icon="Search" :prefix-icon="Search"
/> />
<template v-else> <el-button type="primary" :icon="Shop" @click="marketVisible = true">智能体广场</el-button>
<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>
</div> </div>
</header> </header>
...@@ -35,18 +17,18 @@ ...@@ -35,18 +17,18 @@
<div v-else-if="apps.length === 0" class="empty"> <div v-else-if="apps.length === 0" class="empty">
<p class="empty-title">我的门户还没有应用</p> <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 type="primary" :icon="Shop" class="empty-action" @click="marketVisible = true">
去应用市场 去智能体广场
</el-button> </el-button>
</div> </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-title">没有匹配的应用</p>
<p class="empty-desc">换一个关键词试试。</p> <p class="empty-desc">换一个关键词试试。</p>
</div> </div>
<div v-else-if="!manageMode && keyword.trim()" class="app-grid"> <div v-else-if="keyword.trim()" class="app-grid">
<AppTile <AppTile
v-for="app in filtered" v-for="app in filtered"
:key="app.id" :key="app.id"
...@@ -64,7 +46,7 @@ ...@@ -64,7 +46,7 @@
:class="{ 'is-dragging': dragging }" :class="{ 'is-dragging': dragging }"
ghost-class="app-ghost" ghost-class="app-ghost"
draggable=".slot-cell" draggable=".slot-cell"
filter=".slot-cell.is-empty, .app-actions" filter=".slot-cell.is-empty"
:prevent-on-filter="false" :prevent-on-filter="false"
:animation="180" :animation="180"
:disabled="savingOrder" :disabled="savingOrder"
...@@ -81,10 +63,6 @@ ...@@ -81,10 +63,6 @@
<AppTile <AppTile
v-if="element.app" v-if="element.app"
:app="element.app" :app="element.app"
:manage="manageMode"
:linkable="!manageMode"
@edit="editSlot(element)"
@remove="removeSlot(element)"
@menu="openContextMenu" @menu="openContextMenu"
/> />
<span v-else class="empty-slot" aria-hidden="true"></span> <span v-else class="empty-slot" aria-hidden="true"></span>
...@@ -92,13 +70,6 @@ ...@@ -92,13 +70,6 @@
</template> </template>
</draggable> </draggable>
<AppEditorDialog
v-model="editorVisible"
:app="editingApp"
:next-sort-order="nextSortOrder"
@saved="loadApps"
/>
<AppMarketDialog v-model="marketVisible" @changed="loadApps" /> <AppMarketDialog v-model="marketVisible" @changed="loadApps" />
<Teleport to="body"> <Teleport to="body">
...@@ -121,15 +92,12 @@ ...@@ -121,15 +92,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' 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 draggable from 'vuedraggable'
import AppTile from '@/components/AppTile.vue' import AppTile from '@/components/AppTile.vue'
import AppEditorDialog from '@/components/AppEditorDialog.vue'
import AppMarketDialog from '@/components/AppMarketDialog.vue' import AppMarketDialog from '@/components/AppMarketDialog.vue'
import { import {
adminApi,
portalApi, portalApi,
type AdminApp,
type PortalApp, type PortalApp,
} from '@/api/portal' } from '@/api/portal'
import { apiMessage } from '@/api/http' import { apiMessage } from '@/api/http'
...@@ -147,20 +115,16 @@ interface ContextMenuState { ...@@ -147,20 +115,16 @@ interface ContextMenuState {
app: PortalApp | null app: PortalApp | null
} }
const GRID_COLUMNS = 6 const GRID_COLUMNS = 8
const GRID_MIN_ROWS = 3 const GRID_MIN_ROWS = 3
const GRID_MAX_SLOTS = 200 const GRID_MAX_SLOTS = 200
const auth = useAuthStore() const auth = useAuthStore()
const apps = ref<PortalApp[]>([]) const apps = ref<PortalApp[]>([])
const gridSlots = ref<GridSlot[]>([]) const gridSlots = ref<GridSlot[]>([])
const adminApps = ref<AdminApp[]>([])
const loading = ref(true) const loading = ref(true)
const keyword = ref('') const keyword = ref('')
const manageMode = ref(false)
const editorVisible = ref(false)
const marketVisible = ref(false) const marketVisible = ref(false)
const editingApp = ref<AdminApp | null>(null)
const savingOrder = ref(false) const savingOrder = ref(false)
const dragging = ref(false) const dragging = ref(false)
const justDragged = ref(false) const justDragged = ref(false)
...@@ -174,12 +138,6 @@ let suppressClickTimer: number | undefined ...@@ -174,12 +138,6 @@ let suppressClickTimer: number | undefined
let dragStartIndex: number | null = null let dragStartIndex: number | null = null
let dragSnapshot: GridSlot[] = [] 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 filtered = computed(() => {
const term = keyword.value.trim().toLowerCase() const term = keyword.value.trim().toLowerCase()
if (!term) return apps.value if (!term) return apps.value
...@@ -194,12 +152,6 @@ async function loadApps() { ...@@ -194,12 +152,6 @@ async function loadApps() {
const { data } = await portalApi.apps() const { data } = await portalApi.apps()
apps.value = data apps.value = data
gridSlots.value = buildSlots(data) gridSlots.value = buildSlots(data)
if (isAdmin.value) {
const { data: full } = await adminApi.listApps()
adminApps.value = full
} else {
adminApps.value = []
}
} catch (error) { } catch (error) {
ElMessage.error(apiMessage(error)) ElMessage.error(apiMessage(error))
} finally { } finally {
...@@ -207,23 +159,6 @@ async function loadApps() { ...@@ -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[] { function buildSlots(items: PortalApp[]): GridSlot[] {
const byPosition = new Map<number, PortalApp>() const byPosition = new Map<number, PortalApp>()
const used = new Set<number>() const used = new Set<number>()
...@@ -270,18 +205,6 @@ function buildSlots(items: PortalApp[]): GridSlot[] { ...@@ -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 }) { function onDragStart(event: { oldIndex?: number }) {
dragStartIndex = typeof event.oldIndex === 'number' ? event.oldIndex : null dragStartIndex = typeof event.oldIndex === 'number' ? event.oldIndex : null
dragSnapshot = gridSlots.value.map((slot) => ({ key: slot.key, app: slot.app })) dragSnapshot = gridSlots.value.map((slot) => ({ key: slot.key, app: slot.app }))
...@@ -294,7 +217,7 @@ let pointerStartY = 0 ...@@ -294,7 +217,7 @@ let pointerStartY = 0
function onGridPointerDown(event: PointerEvent) { function onGridPointerDown(event: PointerEvent) {
if (event.button !== 0) return if (event.button !== 0) return
const target = event.target as HTMLElement | null 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 return
} }
pointerDown = true pointerDown = true
...@@ -350,7 +273,7 @@ async function uninstallApp() { ...@@ -350,7 +273,7 @@ async function uninstallApp() {
async function removeFromPortal(app: PortalApp) { async function removeFromPortal(app: PortalApp) {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`从我的门户移除「${app.appName}」?只隐藏你自己的显示,不会下架应用,也不影响其他用户;之后可以在应用市场重新添加。`, `从我的门户移除「${app.appName}」?只隐藏你自己的显示,不会下架应用,也不影响其他用户;之后可以在智能体广场重新添加。`,
'移除应用', '移除应用',
{ {
type: 'warning', type: 'warning',
...@@ -364,20 +287,13 @@ async function removeFromPortal(app: PortalApp) { ...@@ -364,20 +287,13 @@ async function removeFromPortal(app: PortalApp) {
} }
try { try {
await portalApi.hideApp(app.id) await portalApi.hideApp(app.id)
ElMessage.success('已从我的门户移除,可在应用市场重新添加') ElMessage.success('已从我的门户移除,可在智能体广场重新添加')
await loadApps() await loadApps()
} catch (error) { } catch (error) {
ElMessage.error(apiMessage(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 }) { async function persistLayout(event: { newIndex?: number }) {
dragging.value = false dragging.value = false
...@@ -471,134 +387,117 @@ onBeforeUnmount(() => { ...@@ -471,134 +387,117 @@ onBeforeUnmount(() => {
.launcher { .launcher {
position: relative; position: relative;
z-index: 1; z-index: 1;
max-width: 1120px; max-width: var(--content-max);
margin: 0 auto; margin: clamp(24px, 3.2vh, 42px) auto;
padding: 64px 40px 88px; padding: clamp(12px, 1.28vw, 20px) var(--page-pad-x) clamp(36px, 4vw, 56px);
background: #ffffff;
} }
.launcher-head { .launcher-head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-end;
gap: 24px; gap: var(--gap-lg);
padding-bottom: 22px; padding-bottom: clamp(12px, 1.28vw, 20px);
border-bottom: 1px solid rgba(169, 201, 232, 0.16); border-bottom: 1px solid #e3e8ef;
}
.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);
} }
.launcher-tools { .launcher-tools {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: clamp(9px, 0.8vw, 14px);
flex-wrap: wrap; flex-wrap: wrap;
} }
.launcher-search { .launcher-search {
width: 280px; width: clamp(260px, 20vw, 380px);
} }
.launcher-search :deep(.el-input__wrapper) { .launcher-search :deep(.el-input__wrapper) {
height: 38px; height: clamp(29px, 2.08vw, 37px);
padding: 1px 14px; padding: 1px 14px;
background: rgba(8, 32, 60, 0.66); background: #ffffff;
border-radius: 4px; border-radius: 6px;
box-shadow: 0 0 0 1px rgba(169, 201, 232, 0.22) inset; box-shadow: 0 0 0 1px #e3e8ef inset;
transition: background 0.16s ease, box-shadow 0.16s ease; transition: background 0.16s ease, box-shadow 0.16s ease;
} }
.launcher-search :deep(.el-input__wrapper:hover) { .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) { .launcher-search :deep(.el-input__wrapper.is-focus) {
background: rgba(8, 32, 60, 0.82); background: #ffffff;
box-shadow: box-shadow:
0 0 0 1px rgba(169, 201, 232, 0.58) inset, 0 0 0 1px #1677ff inset,
0 0 0 3px rgba(17, 83, 155, 0.18); 0 0 0 3px rgba(22, 119, 255, 0.12);
} }
.launcher-search :deep(.el-input__inner) { .launcher-search :deep(.el-input__inner) {
color: #edf4fb; color: #1f2937;
} }
.launcher-search :deep(.el-input__inner::placeholder) { .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__prefix-inner),
.launcher-search :deep(.el-input__suffix-inner) { .launcher-search :deep(.el-input__suffix-inner) {
color: rgba(220, 231, 243, 0.5); color: #71809b;
} }
.launcher-tools :deep(.el-button) { .launcher-tools :deep(.el-button) {
height: 38px; height: clamp(29px, 2.08vw, 37px);
padding: 0 14px; padding: 0 clamp(12px, 1.1vw, 18px);
color: #dce7f3; color: #3b4658;
background: rgba(8, 32, 60, 0.56); background: #ffffff;
border-color: rgba(169, 201, 232, 0.22); border-color: #e3e8ef;
border-radius: 4px; border-radius: 6px;
transition: color 0.16s ease, background 0.16s ease, border-color 0.16s ease; 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:hover),
.launcher-tools :deep(.el-button:focus-visible) { .launcher-tools :deep(.el-button:focus-visible) {
color: #f2f7fc; color: #1677ff;
background: rgba(17, 83, 155, 0.52); background: #eaf2ff;
border-color: rgba(169, 201, 232, 0.5); border-color: #bcd7ff;
} }
.launcher-tools :deep(.el-button--primary) { .launcher-tools :deep(.el-button--primary) {
color: #fff; color: #ffffff;
background: #11539b; background: #1677ff;
border-color: #2d6fab; border-color: #1677ff;
} }
.launcher-tools :deep(.el-button--primary:hover) { .launcher-tools :deep(.el-button--primary:hover) {
background: #0b3f78; color: #ffffff;
border-color: #3d7fba; background: #0b5ed7;
border-color: #0b5ed7;
} }
.launcher :focus-visible { .launcher :focus-visible {
outline-color: rgba(169, 201, 232, 0.78); outline-color: #1677ff;
} }
.app-grid { .app-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 22px 8px; gap: clamp(10px, 0.9vw, 18px) clamp(4px, 0.4vw, 6px);
margin-top: 34px; margin-top: clamp(18px, 1.8vw, 32px);
} }
.slot-grid { .slot-grid {
display: grid; display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr)); grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 18px 8px; gap: clamp(8px, 0.8vw, 16px) clamp(4px, 0.4vw, 6px);
margin-top: 30px; margin-top: clamp(18px, 1.8vw, 32px);
} }
.slot-cell { .slot-cell {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
min-height: 138px; min-height: clamp(108px, 7.6vw, 148px);
border-radius: 16px; border-radius: 16px;
transition: background 0.16s ease; transition: background 0.16s ease;
} }
...@@ -608,20 +507,40 @@ onBeforeUnmount(() => { ...@@ -608,20 +507,40 @@ onBeforeUnmount(() => {
} }
.empty-slot { .empty-slot {
width: 84px; width: clamp(62px, 4.64vw, 86px);
height: 84px; height: clamp(62px, 4.64vw, 86px);
margin-top: 10px; margin-top: 10px;
border: 1px dashed rgba(169, 201, 232, 0.18); border: 1px dashed #cfdae8;
border-radius: 20px; border-radius: 16px;
background: rgba(169, 201, 232, 0.025); background: rgba(22, 119, 255, 0.03);
opacity: 0; opacity: 0;
transition: opacity 0.16s ease, border-color 0.16s ease, background 0.16s ease; transition: opacity 0.16s ease, border-color 0.16s ease, background 0.16s ease;
} }
.slot-grid.is-dragging .empty-slot { .slot-grid.is-dragging .empty-slot {
opacity: 1; opacity: 1;
border-color: rgba(169, 201, 232, 0.56); border-color: #1677ff;
background: rgba(17, 83, 155, 0.14); 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 { .app-context-menu {
...@@ -629,10 +548,10 @@ onBeforeUnmount(() => { ...@@ -629,10 +548,10 @@ onBeforeUnmount(() => {
z-index: 3000; z-index: 3000;
min-width: 180px; min-width: 180px;
padding: 6px; padding: 6px;
background: rgba(8, 32, 60, 0.97); background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(169, 201, 232, 0.24); border: 1px solid #e3e8ef;
border-radius: 8px; 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); backdrop-filter: blur(14px);
} }
...@@ -643,7 +562,7 @@ onBeforeUnmount(() => { ...@@ -643,7 +562,7 @@ onBeforeUnmount(() => {
width: 100%; width: 100%;
padding: 9px 12px; padding: 9px 12px;
font-size: 13px; font-size: 13px;
color: #f0a4aa; color: #b3262e;
text-align: left; text-align: left;
background: transparent; background: transparent;
border: none; border: none;
...@@ -652,7 +571,7 @@ onBeforeUnmount(() => { ...@@ -652,7 +571,7 @@ onBeforeUnmount(() => {
} }
.context-menu-item:hover { .context-menu-item:hover {
background: rgba(179, 38, 46, 0.18); background: #fef2f2;
} }
.context-menu-item .el-icon { .context-menu-item .el-icon {
...@@ -662,17 +581,16 @@ onBeforeUnmount(() => { ...@@ -662,17 +581,16 @@ onBeforeUnmount(() => {
.state-line { .state-line {
padding: 56px 0; padding: 56px 0;
font-size: 13px; font-size: 13px;
color: rgba(220, 231, 243, 0.5); color: #71809b;
} }
.empty { .empty {
margin-top: 34px; margin-top: 34px;
padding: 64px 24px; padding: 64px 24px;
text-align: center; text-align: center;
background: rgba(8, 32, 60, 0.38); background: #ffffff;
border: 1px dashed rgba(169, 201, 232, 0.22); border: 1px dashed #cfdae8;
border-radius: 8px; border-radius: 10px;
backdrop-filter: blur(6px);
} }
.empty-title { .empty-title {
...@@ -681,13 +599,13 @@ onBeforeUnmount(() => { ...@@ -681,13 +599,13 @@ onBeforeUnmount(() => {
font-size: 22px; font-size: 22px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.03em; letter-spacing: 0.03em;
color: #f2f7fc; color: #1f2937;
} }
.empty-desc { .empty-desc {
margin: 10px 0 0; margin: 10px 0 0;
font-size: 13px; font-size: 13px;
color: rgba(220, 231, 243, 0.55); color: #71809b;
} }
.empty-action { .empty-action {
...@@ -695,13 +613,13 @@ onBeforeUnmount(() => { ...@@ -695,13 +613,13 @@ onBeforeUnmount(() => {
} }
.empty :deep(.el-button--primary) { .empty :deep(.el-button--primary) {
background: #11539b; background: #1677ff;
border-color: #2d6fab; border-color: #1677ff;
} }
.empty :deep(.el-button--primary:hover) { .empty :deep(.el-button--primary:hover) {
background: #0b3f78; background: #0b5ed7;
border-color: #3d7fba; border-color: #0b5ed7;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
...@@ -756,16 +674,12 @@ onBeforeUnmount(() => { ...@@ -756,16 +674,12 @@ onBeforeUnmount(() => {
@media (max-height: 820px) { @media (max-height: 820px) {
.launcher { .launcher {
padding-top: 30px; padding-top: 12px;
padding-bottom: 20px; padding-bottom: 20px;
} }
.launcher-head { .launcher-head {
padding-bottom: 16px; padding-bottom: 12px;
}
.launcher-head h1 {
font-size: 28px;
} }
.slot-grid, .slot-grid,
...@@ -781,18 +695,16 @@ onBeforeUnmount(() => { ...@@ -781,18 +695,16 @@ onBeforeUnmount(() => {
@media (max-height: 760px) { @media (max-height: 760px) {
.launcher { .launcher {
padding-top: 20px; padding-top: 9px;
padding-bottom: 14px; padding-bottom: 14px;
} }
.slot-cell { .launcher-head {
min-height: 96px; padding-bottom: 9px;
} }
}
@media (max-width: 520px) { .slot-cell {
.launcher-head h1 { min-height: 96px;
font-size: 26px;
} }
} }
</style> </style>
...@@ -75,11 +75,13 @@ ...@@ -75,11 +75,13 @@
<div class="record-head"> <div class="record-head">
<span>登录时间</span> <span>登录时间</span>
<span>来源 IP</span> <span>来源 IP</span>
<span>方式</span>
<span>结果</span> <span>结果</span>
</div> </div>
<div v-for="record in loginRecords" :key="record.id" class="record-row"> <div v-for="record in loginRecords" :key="record.id" class="record-row">
<span class="record-time">{{ record.createdAt }}</span> <span class="record-time">{{ record.createdAt }}</span>
<span class="record-ip" :title="record.clientIp || ''">{{ record.clientIp || '—' }}</span> <span class="record-ip" :title="record.clientIp || ''">{{ record.clientIp || '—' }}</span>
<span class="record-type">{{ loginTypeLabel(record.loginType) }}</span>
<span class="record-status">成功</span> <span class="record-status">成功</span>
</div> </div>
</div> </div>
...@@ -118,6 +120,12 @@ async function loadLoginRecords() { ...@@ -118,6 +120,12 @@ async function loadLoginRecords() {
} }
} }
function loginTypeLabel(code: string | null) {
if (code === 'PASSWORD') return '账号密码'
if (code === 'CERTIFICATE') return '数字证书'
return '—'
}
function startEditEmail() { function startEditEmail() {
emailInput.value = auth.user?.email ?? '' emailInput.value = auth.user?.email ?? ''
emailError.value = '' emailError.value = ''
...@@ -161,13 +169,13 @@ onMounted(async () => { ...@@ -161,13 +169,13 @@ onMounted(async () => {
.profile { .profile {
position: relative; position: relative;
z-index: 1; z-index: 1;
max-width: 1120px; max-width: var(--content-max);
margin: 0 auto; margin: 0 auto;
padding: 46px 40px 74px; padding: clamp(36px, 3.4vw, 64px) var(--page-pad-x) clamp(56px, 4.5vw, 88px);
} }
.page-head { .page-head {
padding-bottom: 26px; padding-bottom: clamp(20px, 1.8vw, 30px);
border-bottom: 1px solid rgba(169, 201, 232, 0.16); border-bottom: 1px solid rgba(169, 201, 232, 0.16);
} }
...@@ -180,9 +188,9 @@ onMounted(async () => { ...@@ -180,9 +188,9 @@ onMounted(async () => {
} }
h1 { h1 {
margin: 16px 0 12px; margin: clamp(12px, 1.2vw, 18px) 0 clamp(10px, 1vw, 14px);
font-family: var(--display); font-family: var(--display);
font-size: 36px; font-size: var(--display-xl);
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: #f2f7fc; color: #f2f7fc;
...@@ -191,7 +199,7 @@ h1 { ...@@ -191,7 +199,7 @@ h1 {
.lede { .lede {
max-width: 44em; max-width: 44em;
margin: 0; margin: 0;
font-size: 14px; font-size: var(--text-md);
line-height: 1.9; line-height: 1.9;
color: rgba(220, 231, 243, 0.56); color: rgba(220, 231, 243, 0.56);
} }
...@@ -199,12 +207,12 @@ h1 { ...@@ -199,12 +207,12 @@ h1 {
.profile-grid { .profile-grid {
display: grid; display: grid;
grid-template-columns: 1.15fr 1fr; grid-template-columns: 1.15fr 1fr;
gap: 22px; gap: var(--gap-lg);
margin-top: 28px; margin-top: clamp(22px, 2vw, 34px);
} }
.profile-card { .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%); 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: 1px solid rgba(169, 201, 232, 0.16);
border-top: 2px solid rgba(169, 201, 232, 0.5); border-top: 2px solid rgba(169, 201, 232, 0.5);
...@@ -214,9 +222,9 @@ h1 { ...@@ -214,9 +222,9 @@ h1 {
} }
.profile-card h2 { .profile-card h2 {
margin: 0 0 18px; margin: 0 0 clamp(16px, 1.3vw, 22px);
font-family: var(--display); font-family: var(--display);
font-size: 21px; font-size: clamp(20px, 1.5vw, 26px);
font-weight: 600; font-weight: 600;
letter-spacing: 0.03em; letter-spacing: 0.03em;
color: #f2f7fc; color: #f2f7fc;
...@@ -230,10 +238,10 @@ h1 { ...@@ -230,10 +238,10 @@ h1 {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: 24px; gap: var(--gap-lg);
padding: 13px 0; padding: clamp(11px, 0.9vw, 16px) 0;
border-bottom: 1px dashed rgba(169, 201, 232, 0.14); border-bottom: 1px dashed rgba(169, 201, 232, 0.14);
font-size: 13px; font-size: var(--text-md);
} }
.info-row:last-child { .info-row:last-child {
...@@ -336,9 +344,9 @@ h1 { ...@@ -336,9 +344,9 @@ h1 {
.record-head, .record-head,
.record-row { .record-row {
display: grid; 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; align-items: center;
gap: 12px; gap: 8px;
} }
.record-head { .record-head {
...@@ -377,6 +385,12 @@ h1 { ...@@ -377,6 +385,12 @@ h1 {
white-space: nowrap; white-space: nowrap;
} }
.record-type {
font-size: 11px;
color: rgba(220, 231, 243, 0.54);
white-space: nowrap;
}
.record-status { .record-status {
justify-self: end; justify-self: end;
padding: 2px 7px; padding: 2px 7px;
......
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
class="nav-item" class="nav-item"
:class="{ 'is-active': route.path.startsWith('/admin/apps') }" :class="{ 'is-active': route.path.startsWith('/admin/apps') }"
> >
市场管理 智能体广场管理
</RouterLink> </RouterLink>
<RouterLink <RouterLink
to="/admin/users" to="/admin/users"
...@@ -67,24 +67,24 @@ onMounted(statsStore.refresh) ...@@ -67,24 +67,24 @@ onMounted(statsStore.refresh)
<style scoped> <style scoped>
.admin { .admin {
max-width: 1280px; max-width: var(--admin-max);
margin: 0 auto; margin: 0 auto;
padding: 42px 40px 74px; padding: clamp(32px, 3vw, 56px) var(--page-pad-x) clamp(56px, 4.5vw, 88px);
} }
.admin-head { .admin-head {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
justify-content: space-between; justify-content: space-between;
gap: 32px; gap: var(--gap-lg);
padding-bottom: 26px; padding-bottom: clamp(20px, 1.8vw, 30px);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
h1 { h1 {
margin: 14px 0 0; margin: clamp(12px, 1.1vw, 18px) 0 0;
font-family: var(--display); font-family: var(--display);
font-size: 38px; font-size: clamp(34px, 2.8vw, 46px);
font-weight: 600; font-weight: 600;
letter-spacing: 0.03em; letter-spacing: 0.03em;
color: var(--ink); color: var(--ink);
...@@ -100,7 +100,7 @@ h1 { ...@@ -100,7 +100,7 @@ h1 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
padding: 0 24px; padding: 0 clamp(18px, 1.6vw, 30px);
border-left: 1px solid var(--line); border-left: 1px solid var(--line);
} }
...@@ -110,7 +110,7 @@ h1 { ...@@ -110,7 +110,7 @@ h1 {
.stat strong { .stat strong {
font-family: var(--display); font-family: var(--display);
font-size: 28px; font-size: clamp(26px, 2vw, 36px);
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
color: var(--ink); color: var(--ink);
...@@ -118,16 +118,16 @@ h1 { ...@@ -118,16 +118,16 @@ h1 {
.stat span { .stat span {
margin-top: 6px; margin-top: 6px;
font-size: 11px; font-size: clamp(11px, 0.8vw, 13px);
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--muted); color: var(--muted);
} }
.admin-body { .admin-body {
display: grid; display: grid;
grid-template-columns: 168px minmax(0, 1fr); grid-template-columns: clamp(160px, 9vw, 220px) minmax(0, 1fr);
gap: 34px; gap: var(--gap-lg);
margin-top: 30px; margin-top: clamp(22px, 2.2vw, 36px);
} }
.admin-nav { .admin-nav {
...@@ -136,13 +136,13 @@ h1 { ...@@ -136,13 +136,13 @@ h1 {
gap: 2px; gap: 2px;
align-self: start; align-self: start;
position: sticky; position: sticky;
top: 104px; top: calc(clamp(68px, 4.6vw, 92px) + 24px);
padding-top: 4px; padding-top: 4px;
} }
.nav-item { .nav-item {
padding: 11px 14px; padding: clamp(10px, 0.85vw, 14px) clamp(12px, 1vw, 18px);
font-size: 13px; font-size: clamp(13px, 0.95vw, 15px);
color: var(--muted); color: var(--muted);
text-decoration: none; text-decoration: none;
border-left: 2px solid transparent; border-left: 2px solid transparent;
......
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
<div class="view"> <div class="view">
<header class="view-head"> <header class="view-head">
<div> <div>
<h2>应用市场</h2> <h2>智能体广场</h2>
<p>统一维护市场中的应用与统一认证接入;普通用户可自行添加或移除。</p> <p>统一维护智能体广场中的应用与统一认证接入;普通用户可自行添加或移除。</p>
</div> </div>
<el-button type="primary" :icon="Plus" @click="openCreate">新增应用</el-button> <el-button type="primary" :icon="Plus" @click="openCreate">新增应用</el-button>
</header> </header>
...@@ -149,7 +149,7 @@ ...@@ -149,7 +149,7 @@
active-text="上架" active-text="上架"
inactive-text="下架" inactive-text="下架"
/> />
<span class="form-tip">下架后应用不再显示在门户和应用市场,入口暂停访问;已建立的会话不会立即失效。</span> <span class="form-tip">下架后应用不再显示在门户和智能体广场,入口暂停访问;已建立的会话不会立即失效。</span>
</div> </div>
</el-form-item> </el-form-item>
</el-form> </el-form>
...@@ -598,8 +598,8 @@ async function toggleListing(row: AdminApp) { ...@@ -598,8 +598,8 @@ async function toggleListing(row: AdminApp) {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
nextStatus === 0 nextStatus === 0
? `下架「${row.appName}」后,普通用户将无法在应用市场和门户看到该应用,入口也会暂停访问;已打开的子会话不受影响。` ? `下架「${row.appName}」后,普通用户将无法在智能体广场和门户看到该应用,入口也会暂停访问;已打开的子会话不受影响。`
: `重新上架「${row.appName}」?普通用户可以在应用市场中看到并添加该应用。`, : `重新上架「${row.appName}」?普通用户可以在智能体广场中看到并添加该应用。`,
`${actionLabel}应用`, `${actionLabel}应用`,
{ {
type: 'warning', type: 'warning',
...@@ -775,7 +775,7 @@ onMounted(loadApps) ...@@ -775,7 +775,7 @@ onMounted(loadApps)
.view-head h2 { .view-head h2 {
margin: 0; margin: 0;
font-family: var(--display); font-family: var(--display);
font-size: 25px; font-size: clamp(23px, 1.8vw, 30px);
font-weight: 600; font-weight: 600;
letter-spacing: 0.03em; letter-spacing: 0.03em;
color: var(--ink); color: var(--ink);
......
...@@ -57,36 +57,41 @@ ...@@ -57,36 +57,41 @@
<span class="muted-cell">{{ row.createdAt }}</span> <span class="muted-cell">{{ row.createdAt }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="事件" width="112"> <el-table-column label="事件" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="eventTagType(row.eventType)" effect="plain" size="small"> <el-tag :type="eventTagType(row.eventType)" effect="plain" size="small">
{{ eventLabel(row.eventType) }} {{ eventLabel(row.eventType) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </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 }"> <template #default="{ row }">
<span class="mono-cell">{{ row.username || '—' }}</span> <span class="mono-cell">{{ row.username || '—' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="来源 IP" width="150"> <el-table-column label="来源 IP" width="140">
<template #default="{ row }"> <template #default="{ row }">
<span class="mono-cell">{{ row.clientIp || '—' }}</span> <span class="mono-cell">{{ row.clientIp || '—' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="结果" width="76"> <el-table-column label="结果" width="70">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.success ? 'success' : 'danger'" effect="plain" size="small"> <el-tag :type="row.success ? 'success' : 'danger'" effect="plain" size="small">
{{ row.success ? '成功' : '失败' }} {{ row.success ? '成功' : '失败' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="对象" min-width="110"> <el-table-column label="对象" width="140">
<template #default="{ row }"> <template #default="{ row }">
<span class="muted-cell">{{ targetLabel(row) }}</span> <span class="muted-cell">{{ targetLabel(row) }}</span>
</template> </template>
</el-table-column> </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 }"> <template #default="{ row }">
<span class="muted-cell">{{ row.detail || '—' }}</span> <span class="muted-cell">{{ row.detail || '—' }}</span>
</template> </template>
...@@ -126,6 +131,8 @@ ...@@ -126,6 +131,8 @@
<dd>{{ selected.createdAt || '—' }}</dd> <dd>{{ selected.createdAt || '—' }}</dd>
<dt>事件</dt> <dt>事件</dt>
<dd>{{ eventLabel(selected.eventType) }}({{ selected.eventType }})</dd> <dd>{{ eventLabel(selected.eventType) }}({{ selected.eventType }})</dd>
<dt>登录方式</dt>
<dd>{{ loginTypeLabel(selected.loginType) }}</dd>
<dt>结果</dt> <dt>结果</dt>
<dd>{{ selected.success ? '成功' : '失败' }}</dd> <dd>{{ selected.success ? '成功' : '失败' }}</dd>
<dt>用户</dt> <dt>用户</dt>
...@@ -160,9 +167,12 @@ const EVENT_LABELS: Record<string, string> = { ...@@ -160,9 +167,12 @@ const EVENT_LABELS: Record<string, string> = {
LOGIN_SUCCESS: '登录成功', LOGIN_SUCCESS: '登录成功',
LOGIN_FAILURE: '登录失败', LOGIN_FAILURE: '登录失败',
LOGIN_BLOCKED: '登录限流', LOGIN_BLOCKED: '登录限流',
LOGIN_IP_CHANGED: '登录IP变更',
LOGOUT: '退出登录', LOGOUT: '退出登录',
USER_CREATE: '新增用户', USER_CREATE: '新增用户',
USER_UPDATE: '修改用户', USER_UPDATE: '修改用户',
USER_PASSWORD_CHANGE: '修改密码',
USER_AUTO_DISABLE: '自动停用账号',
USER_PASSWORD_RESET: '重置密码', USER_PASSWORD_RESET: '重置密码',
USER_DELETE: '删除用户', USER_DELETE: '删除用户',
USER_IMPORT: '批量导入用户', USER_IMPORT: '批量导入用户',
...@@ -240,17 +250,27 @@ function eventLabel(code: string) { ...@@ -240,17 +250,27 @@ function eventLabel(code: string) {
return EVENT_LABELS[code] ?? code 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' { function eventTagType(code: string): 'success' | 'warning' | 'danger' | 'info' {
if (code === 'LOGIN_SUCCESS') return 'success' if (code === 'LOGIN_SUCCESS') return 'success'
if (code === 'LOGIN_BLOCKED') return 'warning' if (code === 'LOGIN_BLOCKED') return 'warning'
if (code === 'LOGIN_FAILURE' || code.endsWith('_DELETE')) return 'danger' if (code === 'LOGIN_FAILURE' || code === 'LOGIN_IP_CHANGED' || code.endsWith('_DELETE')) return 'danger'
if (code === 'USER_PASSWORD_RESET' || code === 'APP_SECRET_ROTATE') return 'warning' if (code === 'USER_PASSWORD_RESET' || code === 'APP_SECRET_ROTATE'
|| code === 'USER_AUTO_DISABLE') return 'warning'
return 'info' return 'info'
} }
function targetLabel(row: AuditLogEntry) { function targetLabel(row: AuditLogEntry) {
if (!row.targetType) return '—' 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 return row.targetId ? `${type} #${row.targetId}` : type
} }
...@@ -270,7 +290,7 @@ onMounted(load) ...@@ -270,7 +290,7 @@ onMounted(load)
.view-head h2 { .view-head h2 {
margin: 0; margin: 0;
font-family: var(--display); font-family: var(--display);
font-size: 25px; font-size: clamp(23px, 1.8vw, 30px);
font-weight: 600; font-weight: 600;
letter-spacing: 0.03em; letter-spacing: 0.03em;
color: var(--ink); color: var(--ink);
......
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
/> />
<el-button @click="search">搜索</el-button> <el-button @click="search">搜索</el-button>
<span class="toolbar-spacer" /> <span class="toolbar-spacer" />
<el-button :icon="Upload" @click="openImport">批量导入</el-button> <el-button :icon="Download" @click="openImport">批量导入</el-button>
<el-button <el-button
type="danger" type="danger"
plain plain
...@@ -233,7 +233,7 @@ ...@@ -233,7 +233,7 @@
@closed="resetImport" @closed="resetImport"
> >
<div class="import-head"> <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 link type="primary" :icon="Download" :loading="templateLoading" @click="downloadTemplate">
下载模板 下载模板
</el-button> </el-button>
...@@ -253,7 +253,7 @@ ...@@ -253,7 +253,7 @@
<el-icon class="upload-icon"><UploadFilled /></el-icon> <el-icon class="upload-icon"><UploadFilled /></el-icon>
<div class="el-upload__text">拖拽文件到此处,或 <em>点击选择</em></div> <div class="el-upload__text">拖拽文件到此处,或 <em>点击选择</em></div>
<template #tip> <template #tip>
<div class="el-upload__tip">仅支持 .xlsx,文件不超过 5MB;密码和手机号建议设置为文本格式。</div> <div class="el-upload__tip">仅支持 .xlsx,文件不超过 5MB;身份证号和手机号为必填项,建议设置为文本格式。</div>
</template> </template>
</el-upload> </el-upload>
...@@ -290,7 +290,7 @@ ...@@ -290,7 +290,7 @@
import { computed, nextTick, onMounted, reactive, ref } from 'vue' import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules, UploadFile, UploadInstance } 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 { adminApi, type AdminUser, type ImportUserResult } from '@/api/portal'
import { apiMessage } from '@/api/http' import { apiMessage } from '@/api/http'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
...@@ -771,7 +771,7 @@ onMounted(load) ...@@ -771,7 +771,7 @@ onMounted(load)
.view-head h2 { .view-head h2 {
margin: 0; margin: 0;
font-family: var(--display); font-family: var(--display);
font-size: 25px; font-size: clamp(23px, 1.8vw, 30px);
font-weight: 600; font-weight: 600;
letter-spacing: 0.03em; letter-spacing: 0.03em;
color: var(--ink); color: var(--ink);
......
...@@ -13,15 +13,17 @@ export default defineConfig(({ command }) => ({ ...@@ -13,15 +13,17 @@ export default defineConfig(({ command }) => ({
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { target: 'http://localhost:8080' }, '/api': { target: 'http://localhost:8092' },
'/login': { target: 'http://localhost:8080' }, '/login': { target: 'http://localhost:8092' },
'/logout': { target: 'http://localhost:8080' }, '/change-password': { target: 'http://localhost:8092' },
'/oauth2': { target: 'http://localhost:8080' }, '/logout': { target: 'http://localhost:8092' },
'/connect': { target: 'http://localhost:8080' }, '/oauth2': { target: 'http://localhost:8092' },
'/.well-known': { target: 'http://localhost:8080' }, '/connect': { target: 'http://localhost:8092' },
'/userinfo': { target: 'http://localhost:8080' }, '/.well-known': { target: 'http://localhost:8092' },
'/css': { target: 'http://localhost:8080' }, '/userinfo': { target: 'http://localhost:8092' },
'/img': { target: 'http://localhost:8080' }, '/css': { target: 'http://localhost:8092' },
'/js': { target: 'http://localhost:8092' },
'/img': { target: 'http://localhost:8092' },
}, },
}, },
build: { 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