7 Commits

Author SHA1 Message Date
gxwebsoft 397c3c4b08 chore(build): 更新平台版本至1.0.1
- 将pom.xml中的websoft-platform.version从1.0.0升级为1.0.1
- 反映平台层能力升级所需的版本变更
- 保持项目构建配置的最新状态
2026-09-22 16:24:08 +08:00
gxwebsoft a705539e77 build: Maven wrapper 升级到 3.9.16
与平台层迁移同一批改动。验证:clean test 通过,12 项测试全绿。
2026-09-22 10:58:04 +08:00
gxwebsoft c3e46f4bc8 refactor(platform): 移除旧版开放平台模块并迁移至独立仓库
- 删除api返回结果封装(ApiResult)、业务异常(BusinessException)、系统常量(Constants)等基础模块代码
- 移除开放平台上下文(OpenCaller)、租户上下文(OpenTenantContext)及相关拦截器等实现
- 删除开放平台安全配置、安全异常处理、JWT校验配置和相关测试用例
- 调整 MybatisPlusConfig 中对 OpenTenantContext 包路径的引用
- 将开放平台controller、参数、视图、配置、拦截器等迁移至新的独立仓库包名 com.gxwebsoft.platform.openapi
- 开放接口controller添加 @OpenApi 注解用于异常处理匹配
- 更新文档,补充开放平台模块移除和迁移说明,指导后续开发维护
- 保持包名兼容,确保遗留代码引用不变,无需做import改动
2026-09-22 08:50:29 +08:00
gxwebsoft 86e73b68b7 fix(user): 修复密码哈希通过免登录接口暴露问题
- 在 User 实体的 password 和 payPassword 字段添加@JsonProperty(WRITE_ONLY)注解
- 保证密码字段不再通过 HTTP 接口响应体序列化输出
- 保持反序列化能力,注册、改密及批量导入接口不受影响
- RabbitMQ 消息同步时显式补回密码字段,确保 MQ 报文不变
- 新增 UserCredentialSerializationTest,覆盖 HTTP 响应、反序列化和 MQ 同步报文场景
- 文档更新,说明已修复接口列表及仍需决策的免登录接口安全隐患
- 将 server-api 版本升级至 2.0
2026-09-22 08:40:21 +08:00
gxwebsoft 0923635575 feat(open-platform): 新增用户分页查询接口并更新订单权限及时间格式
- 添加 OpenUserController 支持分页查询本租户用户,权限为 sys:user:list
- 引入 OpenUserPageParam 作为用户查询白名单参数类,过滤敏感字段
- 定义 OpenUserVO,确保响应脱敏手机号、邮箱且不包含密码字段
- 修正订单接口权限 scope 由 order:read 改为 shop:shopOrder:list
- 订单及退款时间字段增加 JsonFormat 注解,统一时间格式为 yyyy-MM-dd HH:mm:ss
- 更新权限常量 OpenScopes,新增 SHOP_ORDER_LIST 和 SYS_USER_LIST
- 增加 OpenUserVO 单元测试,确保敏感字段不被泄露且脱敏逻辑正确
- 新增开放平台调用验证脚本 scripts/verify-open-api.sh,覆盖令牌获取及接口调用验证
- 修改接口文档与示例,明确权限 scope 和时间格式要求
- 修复 TenantController 权限校验问题,确保租户字段来源于令牌且不可伪造
- 禁用伪造 tenantId 请求头修改数据范围,保证租户数据隔离安全
2026-09-22 03:18:27 +08:00
gxwebsoft ea5021c3f0 feat(open-platform): 新增用户分页查询接口并更新订单权限及时间格式
- 添加 OpenUserController 支持分页查询本租户用户,权限为 sys:user:list
- 引入 OpenUserPageParam 作为用户查询白名单参数类,过滤敏感字段
- 定义 OpenUserVO,确保响应脱敏手机号、邮箱且不包含密码字段
- 修正订单接口权限 scope 由 order:read 改为 shop:shopOrder:list
- 订单及退款时间字段增加 JsonFormat 注解,统一时间格式为 yyyy-MM-dd HH:mm:ss
- 更新权限常量 OpenScopes,新增 SHOP_ORDER_LIST 和 SYS_USER_LIST
- 增加 OpenUserVO 单元测试,确保敏感字段不被泄露且脱敏逻辑正确
- 新增开放平台调用验证脚本 scripts/verify-open-api.sh,覆盖令牌获取及接口调用验证
- 修改接口文档与示例,明确权限 scope 和时间格式要求
- 修复 TenantController 权限校验问题,确保租户字段来源于令牌且不可伪造
- 禁用伪造 tenantId 请求头修改数据范围,保证租户数据隔离安全
2026-09-22 03:17:18 +08:00
gxwebsoft efccb786fe feat(open-platform): 新增开放平台独立链路及订单接口
- 新增开放平台配置块 application.yml,配置 JWKS、公钥地址、issuer、audience 及接口前缀
- 新增 OpenPlatformSecurityConfig,独立安全链路匹配 /api/open/** 请求
- JwtAuthenticationFilter 增加 shouldNotFilter 跳过 /api/open/**,避免 RS256 验签异常
- 新增 OpenTenantContext 与拦截器,令牌中 tenant_id 作为唯一租户来源,防止伪造请求头
- MybatisPlusConfig 优先使用 OpenTenantContext 里的租户 ID,确保数据隔离正确
- sys_order 表显式在 OrderMapper.xml 中使用 tenant_id 条件过滤,支持开放链路多租户隔离
- 新增开放平台订单分页查询接口 /api/open/v1/order/page,使用独立参数与返回值结构
- 出参 OpenOrderVO 支持手机号脱敏,保障数据安全,且只暴露必要字段
- 调整 SecurityConfig 加 @Order(2),让位给开放链路的 SecurityConfig @Order(1)
- TenantController.page 增加安全修复,未登录状态拒绝访问,防止匿名读取全部租户数据
- 新增开放平台专用异常处理及认证、权限拒绝响应,避免泄露内部异常信息
- 新增 pom 依赖 spring-boot-starter-oauth2-resource-server,用于 RS256 JWT 验签
- 完整开放平台对接文档 docs/OPEN_PLATFORM_INTEGRATION.md,涵盖设计理念与接口契约
- 增加 OpenOrderVO 单元测试,覆盖手机号脱敏、空列表处理等场景,提高可靠性
2026-09-22 03:08:31 +08:00
26 changed files with 1441 additions and 286 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar
+325
View File
@@ -0,0 +1,325 @@
# 开放平台对接说明(server-api / gxwebsoft_core
> 记录日期:2026-09-22 分支:`codex/open-platform-integration`
> 上游控制面:`base-api`(开放平台,负责应用、凭证、权限与令牌签发)
## 1. 背景
开放平台(`base-api`)只负责签发令牌,业务数据仍在业务服务里。第三方应用拿着
`base-api` 签发的 RS256 access token 调用业务接口时,业务服务需要自行完成:
1. 用 JWKS 公钥验签,校验 `iss` / `aud` / `exp`
2. 校验令牌的 `scope` 是否包含接口所需权限;
3. 从令牌的 `tenant_id` 取租户,作为数据隔离条件。
本服务(`server.websoft.top`)在此之上新增了一条独立的**开放接口链路**,
对内控制台链路零影响。
## 2. 关键设计
### 2.1 两条安全链互不干扰
本服务原有 `SecurityConfig` 有两个特点,都不能被开放接口继承:
| 原有行为 | 位置 | 对开放接口的风险 |
| --- | --- | --- |
| `antMatchers(GET, "/**").permitAll()`,即所有 GET 在框架层放行 | `SecurityConfig` | 开放接口挂上去默认就是公开的 |
| 内部令牌是 HS256 对称密钥,由全局 `JwtAuthenticationFilter` 解析 | `JwtAuthenticationFilter` | 用内部密钥解析 RS256 令牌会抛 `UnsupportedJwtException` |
因此新增 `OpenPlatformSecurityConfig``@Order(1)`),只匹配 `/api/open/**`
`anyRequest().authenticated()` 且无任何 `permitAll`;原 `SecurityConfig` 降为 `@Order(2)`
`JwtAuthenticationFilter` 增加 `shouldNotFilter` 跳过 `/api/open/**`
(该过滤器是 `@Component`,会被 Spring Boot 额外注册为普通 Servlet Filter
仅靠安全链隔离并不够)。
### 2.2 租户只来自令牌
原有租户取值顺序是「请求头 `tenantId` → 请求头 `Domain` → 登录用户」
(见 `MybatisPlusConfig``BaseController.getTenantId`),
意味着客户端可以用一个请求头指定租户。开放链路必须堵死这一点:
`OpenTenantInterceptor` 在请求进入 controller 前,从已验签的 JWT 取 `tenant_id`
写入 `OpenTenantContext``MybatisPlusConfig.getTenantId()` 以它作为**最高优先级**
不再读取请求头。令牌没有 `tenant_id` 时直接返回 403,不回退。
### 2.3 订单表需要显式租户条件
`sys_order``MybatisPlusConfig``ignoreTable` 白名单里,**不受多租户插件管辖**,
内部接口是靠「非平台租户强制 `userId = 登录用户`」实现隔离的(按人而非按租户)。
因此 `OrderMapper.xml` 补了一条显式条件:
```xml
<if test="param.tenantId != null">
AND a.tenant_id = #{param.tenantId}
</if>
```
`PageParam` 会跳过名为 `tenantId` 的字段,该参数此前无人设置,所以对既有调用无影响。
## 3. 代码结构
```
本服务的业务侧(留在本仓库)
com.gxwebsoft.openplatform
├─ constant/OpenScopes.java scope 常量
├─ param/OpenOrderPageParam.java 入参白名单(刻意不含 tenantId)
├─ param/OpenUserPageParam.java 用户查询入参白名单
├─ vo/OpenOrderVO.java 出参裁剪 + 手机号脱敏
├─ vo/OpenUserVO.java 出参裁剪 + 手机号/邮箱脱敏,不含密码字段
├─ controller/OpenOrderController.java /api/open/v1/order/page
└─ controller/OpenUserController.java /api/open/v1/user/page
平台层(已迁出到独立仓库 websoft-platform,作为构件依赖引入)
com.gxwebsoft.platform.openapi
├─ OpenApi 标记注解,异常处理器按它匹配
├─ config/OpenPlatformProperties.java 配置:JWKS / iss / aud / 前缀 / 脱敏
├─ config/OpenPlatformJwtConfig.java JwtDecoder + iss/aud 校验器
├─ config/OpenPlatformSecurityConfig.java @Order(1),只匹配 /api/open/**
├─ config/OpenPlatformWebMvcConfig.java 给 /api/open/** 挂租户拦截器
├─ context/OpenCaller.java 调用方身份(全部来自令牌)
├─ context/OpenTenantContext.java 开放链路租户 ThreadLocal
├─ web/OpenTenantInterceptor.java 绑定与清理租户上下文
├─ web/OpenPlatformAuthenticationEntryPoint.java 未认证响应(不含 error 字段)
├─ web/OpenPlatformAccessDeniedHandler.java 无权限响应(不含 error 字段)
├─ web/OpenPlatformExceptionAdvice.java 开放接口专用异常处理,不外泄内部信息
├─ web/OpenPageResult.java 对外分页结构 {list,total,page,limit}
└─ web/JsonResponseWriter.java 响应写出(不依赖业务侧 CommonUtil
```
> **迁移说明(2026-09-22)**:上述平台层文件原先以源码形式放在本仓库的
> `com.gxwebsoft.openplatform.{config,context,web}` 下,现已抽到独立仓库
> `websoft-platform` 并作为 `websoft-platform-openapi` 构件依赖引入。
> 内核 `Constants` / `ApiResult` / `PageResult` / `BusinessException` 同样已迁移,
> 但**包名保持不变**,所以本服务其余代码一行 import 都不用改。
>
> 对外开放的 controller 必须加 `@OpenApi` 注解——平台层的异常处理器按注解匹配,
> 漏加会导致第三方收到带 `error` 字段(内部异常信息)的响应。
>
> 详见 `websoft-platform/docs/PLATFORM_LAYER.md`。
既有文件改动:
| 文件 | 改动 |
| --- | --- |
| `pom.xml` | 新增 `spring-boot-starter-oauth2-resource-server` |
| `SecurityConfig` | 加 `@Order(2)` 让位给开放链 |
| `JwtAuthenticationFilter` | 加 `shouldNotFilter`,跳过 `/api/open/**` |
| `MybatisPlusConfig` | 租户取值优先读 `OpenTenantContext` |
| `OrderMapper.xml` | 补 `tenant_id` 查询条件 |
| `TenantController.page` | **安全修复**:未登录直接拒绝,见第 6 节 |
| `application.yml` | 新增 `open-platform.*` 配置块 |
## 4. 配置项
```yaml
open-platform:
enabled: true
jwk-set-uri: https://base-api.websoft.top/api/v1/oauth/jwks
issuer: https://base-api.websoft.top/api
audience: websoft-open-platform
path-prefix: /api/open
mask-sensitive: true
```
> **不要改用 `spring.security.oauth2.resourceserver.jwt.issuer-uri`**。
> 配置该项后 Spring Security 会在启动时请求 `{issuer}/.well-known/openid-configuration`
> 做 OIDC 发现,而 `base-api` 未提供该文档(实测返回 `{"code":401,"message":"请先登录"}`),
> 会导致启动直接失败。这里只配 JWKS 地址,`iss` / `aud` 用显式校验器声明。
本地联调时可临时指向自建 JWKS
```bash
./mvnw spring-boot:run -Dspring-boot.run.arguments="\
--open-platform.jwk-set-uri=http://127.0.0.1:18999/jwks.json"
```
## 5. 接口契约
### 5.1 订单列表
`GET /api/open/v1/order/page`
| 项 | 说明 |
| --- | --- |
| 认证 | `Authorization: Bearer <base-api accessToken>` |
| 权限 | scope 含 `shop:shopOrder:list`,否则 403 |
| 数据范围 | 令牌 `tenant_id` 对应租户的订单,**不接受也不识别 `tenantId` 参数或请求头** |
| 分页 | `page`(默认 1)、`limit`(默认 20,上限 100 |
| 过滤 | `orderNo`(模糊)、`type``orderStatus``payStatus``payType``createTimeStart``createTimeEnd` |
| 时间格式 | `yyyy-MM-dd HH:mm:ss`,输出库中存储的挂钟时间,不做时区换算 |
响应沿用平台约定(**HTTP 状态码固定 200**,业务结果看 `code`;不返回 `error` 字段):
```json
{
"code": 0,
"message": "操作成功",
"data": {
"list": [
{
"orderId": 1,
"orderNo": "1856321928276439040",
"orderStatus": 1,
"payStatus": true,
"payPrice": 100.00,
"phone": "138****0316",
"createTime": "2026-09-01 10:00:00"
}
],
"total": 6,
"page": 1,
"limit": 20
}
}
```
典型错误:
| code | message | 触发条件 |
| --- | --- | --- |
| 401 | 令牌缺失或无效 | 未带令牌、签名错误、已过期、`iss`/`aud` 不匹配 |
| 403 | 权限不足,请确认应用已获得该接口权限 | 令牌没有 `shop:shopOrder:list` |
| 403 | 令牌缺少租户信息,无法确定数据范围 | 令牌没有 `tenant_id` |
### 5.2 用户列表
`GET /api/open/v1/user/page`
| 项 | 说明 |
| --- | --- |
| 认证 | `Authorization: Bearer <base-api accessToken>` |
| 权限 | scope 含 `sys:user:list`,否则 403 |
| 数据范围 | 令牌 `tenant_id` 对应租户的用户 |
| 分页 | `page`(默认 1)、`limit`(默认 20,上限 100 |
| 过滤 | `username`(模糊)、`nickname`(模糊)、`type``status``createTimeStart``createTimeEnd` |
出参只包含 `userId` / `userCode` / `username` / `nickname` / `realName` / `type` / `sex` /
`sexName` / `phone` / `email` / `emailVerified` / `organizationId` / `organizationName` /
`status` / `auditStatus` / `createTime`
> **为什么必须用独立 VO**`User` 实体对 `password`、`payPassword` 都没有 `@JsonIgnore`
> 而 `UserMapper` 用的是 `SELECT a.*`。内部接口 `/api/system/user/page` 直接把实体返回,
> 响应里带着密码哈希;开放接口如果照抄这个写法就会把凭证交给第三方。
> `OpenUserVOTest` 里有一条断言专门防止这种回归。
> **scope 命名说明**`shop:shopOrder:list` 取自 `sys_menu.authority` 里已有的权限点
> (菜单 157795「查询」、182274「项目订单」),与内部权限体系保持一致。
> 但注意 `gxwebsoft_core` 库里**没有 `shop_*` 表**(只有 `sys_order` / `sys_order_goods`),
> 当前该 scope 守卫的是 `sys_order` 的数据。若后续要对外开放的是商城订单,
> 需要把接口指向商城所在的服务与库,而不是复用本 controller。
## 6. 随本次改动修复的既有问题
`TenantController.page``GET /api/system/tenant/page`)原先没有 `@PreAuthorize`
`SecurityConfig` 对所有 GET 放行;未登录且未指定 `userId` 时会走「无过滤」分支,
**匿名即可分页读取全部租户**(含租户名称、编码、手机号)。已补登录校验:
未登录返回 `{"code":401,"message":"请先登录"}`
登录页的多租户选择来自登录响应里的 `tenants` 列表(`loginBySelectTenant` 流程),
不依赖该接口,因此不影响登录。
> 同类风险可能还有其它「无 `@PreAuthorize` 的 GET 接口」。彻底收口需要先把
> `SecurityConfig` 里 GET 的 `/**` 白名单摘掉,再按真实流量补显式白名单,
> 影响面较大,建议单独排期。
### 6.1 用户密码哈希可通过免登录接口读取(已修复)
`GET /api/system/user/getByUserId/{userId}``SecurityConfig` 的 GET 白名单里,
实测**无需任何凭证**,只带一个 `tenantId` 请求头即可取到用户实体,
而响应里包含 `password``payPassword` 两个 BCrypt 哈希字段:
```bash
curl -H 'tenantId: <任意租户ID>' 'https://server.websoft.top/api/system/user/getByUserId/<用户ID>'
# => {"code":0,"data":{"userId":..., "password":"$2a$10$...", "payPassword":"..."}}
```
根因有两点叠加:`User` 实体对 `password` / `payPassword` 没有做序列化限制,
`UserMapper` 使用 `SELECT a.*`;同时该接口无 `@PreAuthorize` 又落在 GET 白名单里。
`GET /api/system/user/withoutAuth` 属于同一族(免登录 + 返回实体)。
修复方式是在实体上把这两个字段设为只写:
```java
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;
```
只影响序列化(不再输出),不影响任何依赖反序列化写入密码的流程。
`password``payPassword` 都已加上该注解,因此所有返回 `User` 实体的接口一并收敛,
包括 `getByUserId``withoutAuth``getByPhone``getByUnionid``list``page` 等。
> **同步报文已显式保留**`RabbitMQSyncProducer.sendUserSyncMessage` 把 User 转成 Map 时
> 用的是同一个 ObjectMapper,实体改成只写后密码会从报文里消失。为避免悄悄改变
> websopy 侧收到的内容,生产者在转换后显式补回 `password` / `payPassword`。
> 是否继续在 MQ 报文里带密码哈希,需要 websopy 侧确认后另行决定。
> `UserCredentialSerializationTest` 用三个用例锁住了「HTTP 不输出、反序列化可写入、MQ 报文不变」。
### 6.2 同一批免登录接口仍存在的问题(未修复,需决策)
修掉密码后,这一族接口仍然免登录返回完整用户实体,且租户由 `tenantId` 请求头指定:
| 接口 | 问题 |
| --- | --- |
| `GET /api/system/user/getByUserId/{userId}` | 免登录返回用户详情,含 `idCard`(完整 18 位身份证号)、手机号、邮箱、余额 |
| `GET /api/system/user/withoutAuth` | 免登录,一次返回该租户**全部**用户(不受 page/limit 限制,实测租户 5 返回 163 条 × 107 字段) |
| `GET /api/system/user/getByPhone/{phone}` | 免登录按手机号查用户,可被枚举(`mp-react-nextjs` 在用) |
| `GET /api/system/user/getByUnionid/{unionid}` | 免登录 |
| `PUT /api/system/user/updateWithoutLogin` | **完全没有校验**,任何人都能改用户资料 |
| `POST /api/system/user/batchBackUserId` | **完全没有校验**,可批量建用户(`mp-react-nextjs` 在用) |
另有 `updateUserBalanceWithoutLogin``addUserBalanceWithoutLogin``getUserWithoutLogin`
`updateUserOfficeOpenidWithoutLogin` 使用硬编码常量 `authCode == "1700083"` 作为唯一凭证,
该常量对所有租户相同且写在源码里,等同于弱口令。
`id_card` 列存的是**完整 18 位身份证号**(库里 3173 条有值),与实体上的
「身份证号(脱敏)」注释不符,需要单独决定是脱敏还是仅对管理员可见——会直接影响
租户后台的实名审核页面,因此未擅自改动。
## 7. 如何新增一个开放接口
1.`OpenScopes` 增加 scope 常量,并确保 `base-api` 的权限字典里有同名 scope
2.`controller` 下新建对外 controller,路径以 `/api/open/v1/` 开头;
3. 方法上加 `@PreAuthorize("hasAuthority('SCOPE_xxx')")`
4. 入参使用**独立的白名单 DTO**,不要直接暴露内部 `XxxParam`
5. 需要租户时用 `OpenTenantContext.getTenantId()`,不要从参数或请求头取;
6. 出参使用独立的 VO,不要直接返回实体;
7. 若目标表在 `MybatisPlusConfig``ignoreTable` 名单里(如 `sys_order`),
必须在 Mapper 里显式加租户条件。
## 8. 验证方式
单元测试(不依赖 base-api 在线):
```bash
./mvnw -o test -Dtest='OpenPlatformJwtValidationTest,OpenTenantBindingTest,OpenOrderVOTest'
```
覆盖:`iss`/`aud`/`exp` 三条校验规则、租户只来自令牌(伪造 `tenantId` 请求头无效)、
令牌缺租户时拒绝、请求结束清理上下文、出参脱敏与字段裁剪。
端到端脚本 `scripts/verify-open-api.sh`,一条命令跑完「换令牌 → 解令牌 → 调接口 → 负向用例」:
```bash
# 真实链路(需要应用凭证)
OPEN_CLIENT_ID=xxx OPEN_CLIENT_SECRET=yyy ./scripts/verify-open-api.sh
# 已有令牌时跳过换令牌
OPEN_ACCESS_TOKEN=eyJ... BUSINESS_BASE_URL=http://127.0.0.1:8080 TENANT_EXPECT=6 ./scripts/verify-open-api.sh
```
判定标准:
1. 令牌 `scope``shop:shopOrder:list`、含 `tenant_id`
2. 订单列表 `code=0``total``SELECT COUNT(*) FROM sys_order WHERE deleted=0 AND tenant_id=?` 一致;
3. 不带令牌返回 401
4. 伪造 `tenantId` 请求头不改变返回结果。
本地自建 JWKS(不依赖 base-api)的联调方式见第 4 节。
## 9. 待办
- [ ] `base-api` 的权限字典补充 `shop:shopOrder:list` 等业务 scope,并在应用管理页可勾选;
- [ ] 开放流量的限流与配额(当前 `open_app.rate_limit` 只作用于令牌签发);
- [ ] 调用审计埋点(`client_id` / `tenant_id` / `scope` 全链路);
- [ ] `SecurityConfig` 的 GET `/**` 白名单收口;
- [ ] 第二个业务服务出现时,把 `openplatform` 配置与拦截器抽成共享 starter。
+18 -1
View File
@@ -5,7 +5,7 @@
<groupId>com.gxwebsoft</groupId> <groupId>com.gxwebsoft</groupId>
<artifactId>server-api</artifactId> <artifactId>server-api</artifactId>
<version>1.0</version> <version>2.0</version>
<name>server-api</name> <name>server-api</name>
<description>WebSoftApi project for Spring Boot</description> <description>WebSoftApi project for Spring Boot</description>
@@ -24,6 +24,8 @@
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<!-- 平台层版本:升级平台能力只改这一处 -->
<websoft-platform.version>1.0.1</websoft-platform.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
@@ -203,6 +205,21 @@
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
</dependency> </dependency>
<!--
平台层:内核(统一返回/状态码/业务异常)+ 开放平台资源服务能力。
这些类原先以源码形式散在本仓库里,每个业务仓库各复制一份,改一处要改 40 处。
现在改为依赖构件,升级只要改下面的版本号。
配套约定:BOM 只管理 com.gxwebsoft 的构件,不接管 Spring Boot 版本,
所以本服务仍可自行决定 Boot 版本。
-->
<dependency>
<groupId>com.gxwebsoft</groupId>
<artifactId>websoft-platform-openapi</artifactId>
<version>${websoft-platform.version}</version>
</dependency>
<!-- jjwt - 升级到安全版本 --> <!-- jjwt - 升级到安全版本 -->
<dependency> <dependency>
<groupId>io.jsonwebtoken</groupId> <groupId>io.jsonwebtoken</groupId>
+180
View File
@@ -0,0 +1,180 @@
#!/bin/bash
#
# 开放平台接入验证脚本
#
# 作用:验证「应用凭证 → 换取令牌 → 携带令牌调用业务接口 → 租户隔离与权限生效」整条链路。
#
# 用法一(真实链路,需要应用凭证):
# OPEN_CLIENT_ID=xxx OPEN_CLIENT_SECRET=yyy ./scripts/verify-open-api.sh
#
# 用法二(已有令牌,跳过换令牌,用于本地/联调):
# OPEN_ACCESS_TOKEN=eyJ... BUSINESS_BASE_URL=http://127.0.0.1:8080 ./scripts/verify-open-api.sh
#
# 可选环境变量:
# OPEN_BASE_URL 开放平台地址,默认 https://base-api.websoft.top/api
# BUSINESS_BASE_URL 业务服务地址,默认 http://127.0.0.1:8000
# OPEN_SCOPE 期望的权限标识,默认 shop:shopOrder:list
# OPEN_PATH 被测接口路径,默认 /api/open/v1/order/page
# 测用户列表:OPEN_PATH=/api/open/v1/user/page OPEN_SCOPE=sys:user:list
# TENANT_EXPECT 期望命中本租户的记录数,留空则不校验数量(需与数据库核对后填写)
#
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
OPEN_BASE_URL="${OPEN_BASE_URL:-https://base-api.websoft.top/api}"
BUSINESS_BASE_URL="${BUSINESS_BASE_URL:-http://127.0.0.1:8000}"
OPEN_SCOPE="${OPEN_SCOPE:-shop:shopOrder:list}"
OPEN_PATH="${OPEN_PATH:-/api/open/v1/order/page}"
ok() { echo -e "${GREEN}${NC} $1"; }
bad() { echo -e "${RED}${NC} $1"; }
warn() { echo -e "${YELLOW}!${NC} $1"; }
step() { echo -e "\n${BLUE}== $1 ==${NC}"; }
need_cmd() {
command -v "$1" >/dev/null 2>&1 || { bad "缺少命令:$1"; exit 1; }
}
need_cmd curl
need_cmd python3
# 只解包 ApiResult,不校验业务语义
json_field() {
python3 -c "
import json,sys
try:
data = json.load(sys.stdin)
except Exception:
print('')
sys.exit(0)
cur = data
for part in sys.argv[1].split('.'):
if isinstance(cur, dict):
cur = cur.get(part)
else:
cur = None
break
print('' if cur is None else cur)
" "$1"
}
decode_jwt_payload() {
python3 -c "
import base64, json, sys
token = sys.argv[1].strip()
parts = token.split('.')
if len(parts) < 2:
print('{}')
sys.exit(0)
payload = parts[1].replace('-', '+').replace('_', '/')
payload += '=' * ((4 - len(payload) % 4) % 4)
try:
print(json.dumps(json.loads(base64.b64decode(payload)), ensure_ascii=False, indent=2))
except Exception as e:
print('{}')
" "$1"
}
step "0. 目标地址"
echo " 开放平台:$OPEN_BASE_URL"
echo " 业务服务:$BUSINESS_BASE_URL"
echo " 期望权限:$OPEN_SCOPE"
echo " 接口路径:$OPEN_PATH"
step "1. 获取 access token"
if [ -n "${OPEN_ACCESS_TOKEN:-}" ]; then
ACCESS_TOKEN="$OPEN_ACCESS_TOKEN"
ok "使用已提供的 OPEN_ACCESS_TOKEN"
else
if [ -z "${OPEN_CLIENT_ID:-}" ] || [ -z "${OPEN_CLIENT_SECRET:-}" ]; then
bad "请设置 OPEN_CLIENT_ID / OPEN_CLIENT_SECRET,或直接提供 OPEN_ACCESS_TOKEN"
exit 1
fi
TOKEN_RESPONSE=$(curl -s -m 20 -X POST "$OPEN_BASE_URL/v1/oauth/token" \
-H 'Content-Type: application/json' \
-d "{\"grantType\":\"client_credentials\",\"clientId\":\"$OPEN_CLIENT_ID\",\"clientSecret\":\"$OPEN_CLIENT_SECRET\"}")
echo "$TOKEN_RESPONSE" | python3 -m json.tool 2>/dev/null | head -20 || echo "$TOKEN_RESPONSE"
CODE=$(echo "$TOKEN_RESPONSE" | json_field code)
if [ "$CODE" != "0" ]; then
bad "换取令牌失败:$(echo "$TOKEN_RESPONSE" | json_field message)"
exit 1
fi
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | json_field data.accessToken)
ok "换取令牌成功"
fi
if [ -z "$ACCESS_TOKEN" ]; then
bad "accessToken 为空"
exit 1
fi
step "2. 解析令牌声明(仅本地解码,不代表已验签)"
decode_jwt_payload "$ACCESS_TOKEN"
CLAIMS=$(decode_jwt_payload "$ACCESS_TOKEN")
TOKEN_SCOPE=$(echo "$CLAIMS" | json_field scope)
TOKEN_TENANT=$(echo "$CLAIMS" | json_field tenant_id)
if echo " $TOKEN_SCOPE " | grep -q " $OPEN_SCOPE "; then
ok "令牌包含权限 $OPEN_SCOPE"
else
bad "令牌不含 $OPEN_SCOPE,当前 scope${TOKEN_SCOPE:-(空)}"
warn "请确认:1) base-api 权限字典已登记该 scope;2) 已在应用管理里勾选;3) 重新换过令牌"
exit 1
fi
if [ -n "$TOKEN_TENANT" ] && [ "$TOKEN_TENANT" != "None" ]; then
ok "令牌携带租户 tenant_id=$TOKEN_TENANT(业务侧据此隔离数据)"
else
bad "令牌没有 tenant_id,业务接口会返回 403"
exit 1
fi
step "3. 携带令牌调用开放接口"
RESPONSE=$(curl -s -m 20 -H "Authorization: Bearer $ACCESS_TOKEN" \
"$BUSINESS_BASE_URL$OPEN_PATH?page=1&limit=5")
echo "$RESPONSE" | python3 -m json.tool 2>/dev/null | head -40 || echo "$RESPONSE"
CODE=$(echo "$RESPONSE" | json_field code)
if [ "$CODE" != "0" ]; then
bad "调用失败:code=$CODE message=$(echo "$RESPONSE" | json_field message)"
warn "404 说明业务服务未部署该开放接口;403 说明权限或租户不满足"
exit 1
fi
ok "接口调用成功,total=$(echo "$RESPONSE" | json_field data.total)"
if [ -n "${TENANT_EXPECT:-}" ]; then
TOTAL=$(echo "$RESPONSE" | json_field data.total)
if [ "$TOTAL" = "$TENANT_EXPECT" ]; then
ok "条数与预期一致($TOTAL"
else
bad "条数不符:接口返回 $TOTAL,预期 $TENANT_EXPECT"
exit 1
fi
fi
step "4. 负向用例"
NO_TOKEN=$(curl -s -m 20 "$BUSINESS_BASE_URL$OPEN_PATH?page=1&limit=1")
NO_TOKEN_CODE=$(echo "$NO_TOKEN" | json_field code)
if [ "$NO_TOKEN_CODE" = "401" ]; then
ok "未携带令牌被拒绝(code=401"
else
bad "未携带令牌竟然返回 code=$NO_TOKEN_CODE,开放接口可能未走独立安全链"
fi
if [ -n "$TOKEN_TENANT" ] && [ "$TOKEN_TENANT" != "None" ]; then
SPOOF=$(curl -s -m 20 -H "Authorization: Bearer $ACCESS_TOKEN" -H "tenantId: 1" \
"$BUSINESS_BASE_URL$OPEN_PATH?page=1&limit=5")
SPOOF_TOTAL=$(echo "$SPOOF" | json_field data.total)
BASE_TOTAL=$(echo "$RESPONSE" | json_field data.total)
if [ "$SPOOF_TOTAL" = "$BASE_TOTAL" ]; then
ok "伪造 tenantId 请求头无效(仍返回本租户数据)"
else
bad "伪造 tenantId 改变了结果($BASE_TOTAL -> $SPOOF_TOTAL),存在越权风险"
fi
fi
echo
ok "验证完成"
@@ -1,93 +0,0 @@
package com.gxwebsoft.common.core;
/**
* 系统常量
* Created by WebSoft on 2019-10-29 15:55
*/
public class Constants {
/**
* 默认成功码
*/
public static final int RESULT_OK_CODE = 0;
/**
* 默认失败码
*/
public static final int RESULT_ERROR_CODE = 1;
/**
* 默认成功信息
*/
public static final String RESULT_OK_MSG = "操作成功";
/**
* 默认失败信息
*/
public static final String RESULT_ERROR_MSG = "操作失败";
/**
* 无权限错误码
*/
public static final int UNAUTHORIZED_CODE = 403;
/**
* 无权限提示信息
*/
public static final String UNAUTHORIZED_MSG = "没有访问权限";
/**
* 未认证错误码
*/
public static final int UNAUTHENTICATED_CODE = 401;
/**
* 未认证提示信息
*/
public static final String UNAUTHENTICATED_MSG = "请先登录";
/**
* 登录过期错误码
*/
public static final int TOKEN_EXPIRED_CODE = 401;
/**
* 登录过期提示信息
*/
public static final String TOKEN_EXPIRED_MSG = "登录已过期";
/**
* 非法token错误码
*/
public static final int BAD_CREDENTIALS_CODE = 401;
/**
* 非法token提示信息
*/
public static final String BAD_CREDENTIALS_MSG = "请退出重新登录";
/**
* 表示升序的值
*/
public static final String ORDER_ASC_VALUE = "asc";
/**
* 表示降序的值
*/
public static final String ORDER_DESC_VALUE = "desc";
/**
* token通过header传递的名称
*/
public static final String TOKEN_HEADER_NAME = "Authorization";
/**
* token通过参数传递的名称
*/
public static final String TOKEN_PARAM_NAME = "access_token";
/**
* token认证类型
*/
public static final String TOKEN_TYPE = "Bearer";
}
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.gxwebsoft.common.core.utils.RedisUtil; import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.platform.openapi.context.OpenTenantContext;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue; import net.sf.jsqlparser.expression.LongValue;
@@ -40,6 +41,12 @@ public class MybatisPlusConfig {
@Override @Override
public Expression getTenantId() { public Expression getTenantId() {
// 0 开放平台链路:租户只来自令牌,优先级最高
// 必须放在最前面,否则第三方可以伪造 tenantId 请求头越过数据隔离
Integer openTenantId = OpenTenantContext.getTenantId();
if (openTenantId != null) {
return new LongValue(openTenantId);
}
String tenantId; String tenantId;
// 从请求头拿ID // 从请求头拿ID
tenantId = request.getHeader("tenantId"); tenantId = request.getHeader("tenantId");
@@ -1,48 +0,0 @@
package com.gxwebsoft.common.core.exception;
import com.gxwebsoft.common.core.Constants;
/**
* 自定义业务异常
*
* @author WebSoft
* @since 2018-02-22 11:29:28
*/
public class BusinessException extends RuntimeException {
private static final long serialVersionUID = 1L;
private Integer code;
public BusinessException() {
this(Constants.RESULT_ERROR_MSG);
}
public BusinessException(String message) {
this(Constants.RESULT_ERROR_CODE, message);
}
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
}
public BusinessException(Integer code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
public BusinessException(Integer code, String message, Throwable cause,
boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
this.code = code;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
}
@@ -47,6 +47,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Resource @Resource
private LoginRecordService loginRecordService; private LoginRecordService loginRecordService;
/**
* 开放平台接口走独立的安全链与 RS256 验签,不能被本过滤器用内部 HS256 密钥解析。
*
* <p>否则请求会在这里抛出
* {@code UnsupportedJwtException: ... may not be used to verify RS256 signatures}
* 并被包装成「请退出重新登录」。</p>
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
return uri != null && uri.startsWith("/api/open/");
}
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException { throws ServletException, IOException {
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.core.security;
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.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -20,6 +21,7 @@ import javax.annotation.Resource;
* @since 2020-03-23 18:04:52 * @since 2020-03-23 18:04:52
*/ */
@Configuration @Configuration
@Order(2)
@EnableWebSecurity @EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) @EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter { public class SecurityConfig extends WebSecurityConfigurerAdapter {
@@ -1,87 +0,0 @@
package com.gxwebsoft.common.core.web;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.Serializable;
/**
* 返回结果
*
* @author WebSoft
* @since 2017-06-10 10:10:50
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResult<T> implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "状态码")
private Integer code;
@Schema(description = "状态信息")
private String message;
@Schema(description = "返回数据")
private T data;
@Schema(description = "错误信息")
private String error;
public ApiResult() {}
public ApiResult(Integer code) {
this(code, null);
}
public ApiResult(Integer code, String message) {
this(code, message, null);
}
public ApiResult(Integer code, String message, T data) {
this(code, message, data, null);
}
public ApiResult(Integer code, String message, T data, String error) {
setCode(code);
setMessage(message);
setData(data);
setError(error);
}
public Integer getCode() {
return this.code;
}
public ApiResult<T> setCode(Integer code) {
this.code = code;
return this;
}
public String getMessage() {
return this.message;
}
public ApiResult<T> setMessage(String message) {
this.message = message;
return this;
}
public T getData() {
return this.data;
}
public ApiResult<T> setData(T data) {
this.data = data;
return this;
}
public String getError() {
return this.error;
}
public ApiResult<T> setError(String error) {
this.error = error;
return this;
}
}
@@ -1,51 +0,0 @@
package com.gxwebsoft.common.core.web;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.Serializable;
import java.util.List;
/**
* 分页查询返回结果
*
* @author WebSoft
* @since 2017-06-10 10:10:02
*/
public class PageResult<T> implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "当前页数据")
private List<T> list;
@Schema(description = "总数量")
private Long count;
public PageResult() {
}
public PageResult(List<T> list) {
this(list, null);
}
public PageResult(List<T> list, Long count) {
setList(list);
setCount(count);
}
public List<T> getList() {
return this.list;
}
public void setList(List<T> list) {
this.list = list;
}
public Long getCount() {
return this.count;
}
public void setCount(Long count) {
this.count = count;
}
}
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.common.mq.config.RabbitMQConfig; import com.gxwebsoft.common.mq.config.RabbitMQConfig;
import com.gxwebsoft.common.mq.message.SyncMessage; import com.gxwebsoft.common.mq.message.SyncMessage;
import com.gxwebsoft.common.mq.producer.SyncMessageProducer; import com.gxwebsoft.common.mq.producer.SyncMessageProducer;
import com.gxwebsoft.common.system.entity.User;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message; import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageProperties; import org.springframework.amqp.core.MessageProperties;
@@ -108,6 +109,19 @@ public class RabbitMQSyncProducer implements SyncMessageProducer, RabbitTemplate
dataMap = objectMapper.convertValue(userData, Map.class); dataMap = objectMapper.convertValue(userData, Map.class);
} }
// User 实体把 password / payPassword 标记为 WRITE_ONLY(不再出现在任何 HTTP 响应里),
// 但用户同步到 websopy 的消息历来包含这两个字段。这里显式补回,保证本次安全修复
// 不会悄悄改变对端收到的报文;是否保留由 websopy 侧确认后另行决定。
if (userData instanceof User) {
User user = (User) userData;
if (user.getPassword() != null) {
dataMap.put("password", user.getPassword());
}
if (user.getPayPassword() != null) {
dataMap.put("payPassword", user.getPayPassword());
}
}
SyncMessage message = new SyncMessage("USER_SYNC", eventType, targetSystem, dataMap); SyncMessage message = new SyncMessage("USER_SYNC", eventType, targetSystem, dataMap);
sendSyncMessage(message); sendSyncMessage(message);
} catch (Exception e) { } catch (Exception e) {
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.system.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.config.ConfigProperties; import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.exception.BusinessException; import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.core.utils.CommonUtil; import com.gxwebsoft.common.core.utils.CommonUtil;
@@ -58,15 +59,19 @@ public class TenantController extends BaseController {
@Operation(summary = "分页查询租户") @Operation(summary = "分页查询租户")
@GetMapping("/page") @GetMapping("/page")
public ApiResult<PageResult<Tenant>> page(TenantParam param) { public ApiResult<PageResult<Tenant>> page(TenantParam param) {
// 安全修复:SecurityConfig 对所有 GET 放行,本方法原先在未登录且未指定 userId 时
// 会走「无过滤」分支返回全部租户(含租户名称、编码、手机号)。这里补上登录校验。
// 登录页的多租户选择走登录响应里的 tenants 列表,不依赖本接口,因此不影响登录流程。
final User loginUser = getLoginUser();
if (loginUser == null) {
return new ApiResult<>(Constants.UNAUTHENTICATED_CODE, Constants.UNAUTHENTICATED_MSG);
}
// 如果传了 all=true,查询全部租户;否则自动用当前登录用户的 userId // 如果传了 all=true,查询全部租户;否则自动用当前登录用户的 userId
if (param.getAll() == null || !param.getAll()) { if (param.getAll() == null || !param.getAll()) {
if (param.getUserId() == null) { if (param.getUserId() == null && loginUser.getUserId() != null) {
final User loginUser = getLoginUser();
if (loginUser != null && loginUser.getUserId() != null) {
param.setUserId(loginUser.getUserId()); param.setUserId(loginUser.getUserId());
} }
} }
}
PageResult<Tenant> result = tenantService.pageRel(param); PageResult<Tenant> result = tenantService.pageRel(param);
// 如果传入 mask=false,设置不脱敏 // 如果传入 mask=false,设置不脱敏
if (param.getMask() != null && !param.getMask()) { if (param.getMask() != null && !param.getMask()) {
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
import cn.hutool.core.util.DesensitizedUtil; import cn.hutool.core.util.DesensitizedUtil;
import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -40,7 +41,14 @@ public class User implements UserDetails {
@Schema(description = "账号") @Schema(description = "账号")
private String username; private String username;
/**
* 登录密码(BCrypt 哈希)。
*
* <p>只写不读:序列化时永远不输出,避免任何接口把密码哈希返回给调用方;
* 反序列化不受影响,注册 / 改密 / 批量导入仍可正常写入。</p>
*/
@Schema(description = "密码") @Schema(description = "密码")
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password; private String password;
@Schema(description = "昵称") @Schema(description = "昵称")
@@ -86,7 +94,11 @@ public class User implements UserDetails {
@Schema(description = "特长") @Schema(description = "特长")
private String speciality; private String speciality;
/**
* 支付密码(BCrypt 哈希),与登录密码同样只写不读。
*/
@Schema(description = "支付密码") @Schema(description = "支付密码")
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String payPassword; private String payPassword;
@Schema(description = "职务") @Schema(description = "职务")
@@ -8,6 +8,12 @@
FROM sys_order a FROM sys_order a
LEFT JOIN sys_company b ON a.tenant_id = b.tenant_id LEFT JOIN sys_company b ON a.tenant_id = b.tenant_id
<where> <where>
<!-- 租户条件:sys_order 在 MybatisPlusConfig 的 ignoreTable 白名单里,
不受多租户插件管辖,必须在这里显式过滤。目前只有开放平台链路会设置该参数,
内部调用不传即保持原有行为。 -->
<if test="param.tenantId != null">
AND a.tenant_id = #{param.tenantId}
</if>
<if test="param.orderId != null"> <if test="param.orderId != null">
AND a.order_id = #{param.orderId} AND a.order_id = #{param.orderId}
</if> </if>
@@ -0,0 +1,30 @@
package com.gxwebsoft.openplatform.constant;
/**
* 开放平台权限标识(scope)。
*
* <p>与 base-api 的权限字典、以及在管理后台勾选的权限一一对应。</p>
*
* @author WebSoft
*/
public final class OpenScopes {
/**
* 查询项目订单。
*
* <p>与 sys_menu.authority 里的既有权限点同名(菜单 157795「查询」、182274「项目订单」),
* 便于与内部权限体系对照。</p>
*/
public static final String SHOP_ORDER_LIST = "shop:shopOrder:list";
/**
* 查询用户列表。
*
* <p>与 sys_menu.authority 里的既有权限点同名(菜单 3561 等「查询」)。</p>
*/
public static final String SYS_USER_LIST = "sys:user:list";
private OpenScopes() {
}
}
@@ -0,0 +1,92 @@
package com.gxwebsoft.openplatform.controller;
import cn.hutool.core.util.StrUtil;
import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.Order;
import com.gxwebsoft.common.system.param.OrderParam;
import com.gxwebsoft.common.system.service.OrderService;
import com.gxwebsoft.platform.openapi.OpenApi;
import com.gxwebsoft.platform.openapi.config.OpenPlatformProperties;
import com.gxwebsoft.openplatform.constant.OpenScopes;
import com.gxwebsoft.platform.openapi.context.OpenTenantContext;
import com.gxwebsoft.openplatform.param.OpenOrderPageParam;
import com.gxwebsoft.openplatform.vo.OpenOrderVO;
import com.gxwebsoft.platform.openapi.web.OpenPageResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
/**
* 开放平台 · 订单接口。
*
* <p>路径带版本号 {@code /api/open/v1/}:对外接口是长期契约,发布后只增不改。</p>
*
* <p>鉴权分两层:框架层由 {@code OpenPlatformSecurityConfig} 校验 RS256 令牌的签名与 iss/aud/exp
* 权限层由 {@code @PreAuthorize} 校验 scope。租户由 {@code OpenTenantInterceptor} 从令牌落到上下文。</p>
*
* <p>随 {@code open-platform.enabled} 一起装配:开关关闭时接口整体下线,
* 而不是退化到内部控制台的安全链(那条链对所有 GET 放行)。</p>
*
* @author WebSoft
*/
@Tag(name = "开放接口-订单")
@OpenApi
@RestController
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
@RequestMapping("/api/open/v1/order")
public class OpenOrderController {
@Resource
private OrderService orderService;
@Resource
private OpenPlatformProperties properties;
@PreAuthorize("hasAuthority('SCOPE_" + OpenScopes.SHOP_ORDER_LIST + "')")
@Operation(summary = "分页查询本租户订单")
@GetMapping("/page")
public ApiResult<OpenPageResult<OpenOrderVO>> page(@AuthenticationPrincipal Jwt jwt,
OpenOrderPageParam query) {
// 租户只认令牌:拦截器已校验过 tenant_id 存在,这里不会拿到请求头里的值
Integer tenantId = OpenTenantContext.getTenantId();
OrderParam param = new OrderParam();
param.setPage(normalizePage(query.getPage()));
param.setLimit(normalizeLimit(query.getLimit()));
param.setTenantId(tenantId);
param.setOrderNo(StrUtil.trimToNull(query.getOrderNo()));
param.setType(query.getType());
param.setOrderStatus(query.getOrderStatus());
param.setPayStatus(query.getPayStatus());
param.setPayType(query.getPayType());
param.setCreateTimeStart(StrUtil.trimToNull(query.getCreateTimeStart()));
param.setCreateTimeEnd(StrUtil.trimToNull(query.getCreateTimeEnd()));
PageResult<Order> result = orderService.pageRel(param);
List<OpenOrderVO> list = OpenOrderVO.from(result.getList(), properties.isMaskSensitive());
return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG,
new OpenPageResult<>(list, result.getCount(), param.getPage(), param.getLimit()));
}
private Long normalizePage(Long page) {
return (page == null || page < 1L) ? 1L : page;
}
private Long normalizeLimit(Long limit) {
if (limit == null || limit < 1L) {
return 20L;
}
return Math.min(limit, OpenOrderPageParam.MAX_LIMIT);
}
}
@@ -0,0 +1,84 @@
package com.gxwebsoft.openplatform.controller;
import cn.hutool.core.util.StrUtil;
import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.common.system.param.UserParam;
import com.gxwebsoft.common.system.service.UserService;
import com.gxwebsoft.platform.openapi.OpenApi;
import com.gxwebsoft.platform.openapi.config.OpenPlatformProperties;
import com.gxwebsoft.openplatform.constant.OpenScopes;
import com.gxwebsoft.platform.openapi.context.OpenTenantContext;
import com.gxwebsoft.openplatform.param.OpenUserPageParam;
import com.gxwebsoft.openplatform.vo.OpenUserVO;
import com.gxwebsoft.platform.openapi.web.OpenPageResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
/**
* 开放平台 · 用户接口。
*
* <p>{@code sys_user} 不在 {@code MybatisPlusConfig} 的 {@code ignoreTable} 白名单里,
* 多租户插件会自动加上 {@code tenant_id} 条件;这里仍显式设置一次,
* 两个来源都指向令牌里的租户({@code OpenTenantContext}),属于双保险。</p>
*
* @author WebSoft
*/
@Tag(name = "开放接口-用户")
@OpenApi
@RestController
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
@RequestMapping("/api/open/v1/user")
public class OpenUserController {
@Resource
private UserService userService;
@Resource
private OpenPlatformProperties properties;
@PreAuthorize("hasAuthority('SCOPE_" + OpenScopes.SYS_USER_LIST + "')")
@Operation(summary = "分页查询本租户用户")
@GetMapping("/page")
public ApiResult<OpenPageResult<OpenUserVO>> page(OpenUserPageParam query) {
// 租户只认令牌,拦截器已校验 tenant_id 存在
Integer tenantId = OpenTenantContext.getTenantId();
UserParam param = new UserParam();
param.setPage(normalizePage(query.getPage()));
param.setLimit(normalizeLimit(query.getLimit()));
param.setTenantId(tenantId);
param.setUsername(StrUtil.trimToNull(query.getUsername()));
param.setNickname(StrUtil.trimToNull(query.getNickname()));
param.setType(query.getType());
param.setStatus(query.getStatus());
param.setCreateTimeStart(StrUtil.trimToNull(query.getCreateTimeStart()));
param.setCreateTimeEnd(StrUtil.trimToNull(query.getCreateTimeEnd()));
PageResult<User> result = userService.pageRel(param);
List<OpenUserVO> list = OpenUserVO.from(result.getList(), properties.isMaskSensitive());
return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG,
new OpenPageResult<>(list, result.getCount(), param.getPage(), param.getLimit()));
}
private Long normalizePage(Long page) {
return (page == null || page < 1L) ? 1L : page;
}
private Long normalizeLimit(Long limit) {
if (limit == null || limit < 1L) {
return 20L;
}
return Math.min(limit, OpenUserPageParam.MAX_LIMIT);
}
}
@@ -0,0 +1,52 @@
package com.gxwebsoft.openplatform.param;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 开放接口订单分页查询入参。
*
* <p>刻意不复用内部的 {@code OrderParam}:后者带着 {@code userId}、{@code keywords}、{@code deleted}
* 等可被构造的字段,直接暴露给第三方等于把内部查询能力整体开放。这里只保留白名单字段,
* 且<b>不提供 tenantId</b>——租户只来自令牌。</p>
*
* @author WebSoft
*/
@Data
@Schema(name = "OpenOrderPageParam", description = "开放接口订单分页查询参数")
public class OpenOrderPageParam implements Serializable {
private static final long serialVersionUID = 1L;
/** 每页最大条数,防止第三方一次拉全量 */
public static final long MAX_LIMIT = 100L;
@Schema(description = "页码,从 1 开始", example = "1")
private Long page = 1L;
@Schema(description = "每页数量,最大 100", example = "20")
private Long limit = 20L;
@Schema(description = "订单编号,模糊匹配")
private String orderNo;
@Schema(description = "订单类型,0产品 1插件")
private Integer type;
@Schema(description = "订单状态,0未完成 1已完成 2已取消 ...")
private Integer orderStatus;
@Schema(description = "是否已付款")
private Boolean payStatus;
@Schema(description = "支付方式")
private Integer payType;
@Schema(description = "下单时间起始,闭区间,格式 yyyy-MM-dd HH:mm:ss")
private String createTimeStart;
@Schema(description = "下单时间结束,闭区间,格式 yyyy-MM-dd HH:mm:ss")
private String createTimeEnd;
}
@@ -0,0 +1,47 @@
package com.gxwebsoft.openplatform.param;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 开放接口用户分页查询入参。
*
* <p>白名单字段,刻意不含 {@code password}、{@code tenantId} 等内部可构造字段。</p>
*
* @author WebSoft
*/
@Data
@Schema(name = "OpenUserPageParam", description = "开放接口用户分页查询参数")
public class OpenUserPageParam implements Serializable {
private static final long serialVersionUID = 1L;
/** 每页最大条数 */
public static final long MAX_LIMIT = 100L;
@Schema(description = "页码,从 1 开始", example = "1")
private Long page = 1L;
@Schema(description = "每页数量,最大 100", example = "20")
private Long limit = 20L;
@Schema(description = "账号,模糊匹配")
private String username;
@Schema(description = "昵称,模糊匹配")
private String nickname;
@Schema(description = "用户类型")
private Integer type;
@Schema(description = "用户状态")
private Integer status;
@Schema(description = "注册时间起始,格式 yyyy-MM-dd HH:mm:ss")
private String createTimeStart;
@Schema(description = "注册时间结束,格式 yyyy-MM-dd HH:mm:ss")
private String createTimeEnd;
}
@@ -0,0 +1,134 @@
package com.gxwebsoft.openplatform.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.gxwebsoft.common.system.entity.Order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
/**
* 开放接口的订单出参。
*
* <p>只暴露第三方确实需要的字段,避免把内部实体(含 adminUrl、menuParam、deleted 等)整体吐出去。</p>
*
* @author WebSoft
*/
@Data
@Schema(name = "OpenOrder", description = "开放接口订单")
public class OpenOrderVO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "订单id")
private Integer orderId;
@Schema(description = "订单编号")
private String orderNo;
@Schema(description = "订单类型,0产品 1插件")
private Integer type;
@Schema(description = "下单渠道,0网站 1小程序 2其他")
private Integer channel;
@Schema(description = "订单总额")
private BigDecimal totalPrice;
@Schema(description = "优惠金额")
private BigDecimal reducePrice;
@Schema(description = "实际付款金额")
private BigDecimal payPrice;
@Schema(description = "退款金额")
private BigDecimal refundMoney;
@Schema(description = "购买数量")
private Integer totalNum;
@Schema(description = "支付方式")
private Integer payType;
@Schema(description = "是否已付款")
private Boolean payStatus;
@Schema(description = "订单状态")
private Integer orderStatus;
@Schema(description = "第三方支付订单号")
private String transactionId;
@Schema(description = "下单人姓名")
private String realName;
@Schema(description = "下单人手机号")
private String phone;
@Schema(description = "备注")
private String comments;
// 与 Order 实体的序列化保持一致:输出库中存储的挂钟时间,不做时区换算。
// 注意 JacksonConfig 里的 @Primary ObjectMapper 是新建的,不加载 spring.jackson.* 配置,
// 不显式声明格式会退化成 ISO-86012024-11-12T21:03:31.000+00:00)。
@Schema(description = "支付时间,格式 yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date payTime;
@Schema(description = "退款时间,格式 yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date refundTime;
@Schema(description = "下单时间,格式 yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/**
* 实体转出参。
*
* @param orders 订单列表
* @param maskSensitive 是否对手机号脱敏(默认开启,避免把终端用户信息直接交给第三方)
*/
public static List<OpenOrderVO> from(List<Order> orders, boolean maskSensitive) {
if (orders == null || orders.isEmpty()) {
return Collections.emptyList();
}
List<OpenOrderVO> list = new ArrayList<>(orders.size());
for (Order order : orders) {
OpenOrderVO vo = new OpenOrderVO();
vo.setOrderId(order.getOrderId());
vo.setOrderNo(order.getOrderNo());
vo.setType(order.getType());
vo.setChannel(order.getChannel());
vo.setTotalPrice(order.getTotalPrice());
vo.setReducePrice(order.getReducePrice());
vo.setPayPrice(order.getPayPrice());
vo.setRefundMoney(order.getRefundMoney());
vo.setTotalNum(order.getTotalNum());
vo.setPayType(order.getPayType());
vo.setPayStatus(order.getPayStatus());
vo.setOrderStatus(order.getOrderStatus());
vo.setTransactionId(order.getTransactionId());
vo.setRealName(order.getRealName());
vo.setPhone(maskSensitive ? maskPhone(order.getPhone()) : order.getPhone());
vo.setComments(order.getComments());
vo.setPayTime(order.getPayTime());
vo.setRefundTime(order.getRefundTime());
vo.setCreateTime(order.getCreateTime());
list.add(vo);
}
return list;
}
private static String maskPhone(String phone) {
if (phone == null || phone.length() < 7) {
return phone;
}
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
}
@@ -0,0 +1,131 @@
package com.gxwebsoft.openplatform.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.gxwebsoft.common.system.entity.User;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 开放接口的用户出参。
*
* <p><b>安全要点</b>{@code User} 实体没有对 {@code password} 做 {@code @JsonIgnore}
* 且 UserMapper 用的是 {@code SELECT a.*},直接把实体返回给第三方会泄露密码哈希与支付密码。
* 这里只挑出必要字段,密码类字段一律不出现。</p>
*
* @author WebSoft
*/
@Data
@Schema(name = "OpenUser", description = "开放接口用户")
public class OpenUserVO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "用户id")
private Integer userId;
@Schema(description = "用户编码")
private String userCode;
@Schema(description = "账号")
private String username;
@Schema(description = "昵称")
private String nickname;
@Schema(description = "真实姓名")
private String realName;
@Schema(description = "用户类型")
private Integer type;
@Schema(description = "性别字典值")
private String sex;
@Schema(description = "性别名称")
private String sexName;
@Schema(description = "手机号(按配置脱敏)")
private String phone;
@Schema(description = "邮箱(按配置脱敏)")
private String email;
@Schema(description = "邮箱是否验证,0否 1是")
private Integer emailVerified;
@Schema(description = "机构id")
private Integer organizationId;
@Schema(description = "机构名称")
private String organizationName;
@Schema(description = "状态")
private Integer status;
@Schema(description = "审核状态:0待审核 1已通过 2已拒绝")
private Integer auditStatus;
@Schema(description = "注册时间,格式 yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
/**
* 实体转出参。
*
* @param users 用户列表
* @param maskSensitive 是否对手机号、邮箱脱敏
*/
public static List<OpenUserVO> from(List<User> users, boolean maskSensitive) {
if (users == null || users.isEmpty()) {
return Collections.emptyList();
}
List<OpenUserVO> list = new ArrayList<>(users.size());
for (User user : users) {
OpenUserVO vo = new OpenUserVO();
vo.setUserId(user.getUserId());
vo.setUserCode(user.getUserCode());
vo.setUsername(user.getUsername());
vo.setNickname(user.getNickname());
vo.setRealName(user.getRealName());
vo.setType(user.getType());
vo.setSex(user.getSex());
vo.setSexName(user.getSexName());
vo.setPhone(maskSensitive ? maskPhone(user.getPhone()) : user.getPhone());
vo.setEmail(maskSensitive ? maskEmail(user.getEmail()) : user.getEmail());
vo.setEmailVerified(user.getEmailVerified());
vo.setOrganizationId(user.getOrganizationId());
vo.setOrganizationName(user.getOrganizationName());
vo.setStatus(user.getStatus());
vo.setAuditStatus(user.getAuditStatus());
vo.setCreateTime(user.getCreateTime());
list.add(vo);
}
return list;
}
private static String maskPhone(String phone) {
if (phone == null || phone.length() < 7) {
return phone;
}
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
private static String maskEmail(String email) {
if (email == null) {
return null;
}
int at = email.indexOf('@');
if (at <= 0) {
// 不是正常的邮箱格式,原样返回,不臆造
return email;
}
// 统一保留局部名首字符与完整域名:zhangsan@example.com -> z***@example.com
return email.charAt(0) + "***" + email.substring(at);
}
}
+16
View File
@@ -168,3 +168,19 @@ certificate:
alipay-cert-public-key-file: "alipayCertPublicKey.crt" alipay-cert-public-key-file: "alipayCertPublicKey.crt"
alipay-root-cert-file: "alipayRootCert.crt" alipay-root-cert-file: "alipayRootCert.crt"
# 开放平台(base-api)对接配置
# 业务服务只做本地验签,不共享密钥;/api/open/** 走独立安全链。
open-platform:
enabled: true
# base-api 的 JWKS 公钥地址
# 注意:不要改用 spring.security.oauth2.resourceserver.jwt.issuer-uri
# Spring 会去做 OIDC 发现(base-api 没有该文档)导致启动失败。
jwk-set-uri: https://base-api.websoft.top/api/v1/oauth/jwks
# 必须与 base-api 令牌里的 iss 完全一致
issuer: https://base-api.websoft.top/api
# 必须与 base-api 令牌里的 aud 一致
audience: websoft-open-platform
# 对外接口前缀,版本号由 controller 的 @RequestMapping 决定
path-prefix: /api/open
# 返回给第三方前是否对手机号脱敏
mask-sensitive: true
@@ -0,0 +1,99 @@
package com.gxwebsoft.common.system;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.common.mq.config.RabbitMQConfig;
import com.gxwebsoft.common.mq.message.SyncMessage;
import com.gxwebsoft.common.mq.producer.impl.RabbitMQSyncProducer;
import com.gxwebsoft.common.system.entity.User;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.MessageConverter;
import java.util.Map;
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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* 用户密码字段的序列化契约测试。
*
* <p>背景:{@code User} 实体被十几个接口直接当响应体返回,其中
* {@code GET /api/system/user/getByUserId/{id}}、{@code /withoutAuth} 等还免登录,
* 原先响应里带着 BCrypt 密码哈希。修复方式是把 password / payPassword 设为 WRITE_ONLY
* 这里的三个用例分别锁住:HTTP 不再输出、反序列化仍可写入、MQ 同步报文保持原样。</p>
*
* @author WebSoft
*/
class UserCredentialSerializationTest {
private static final String PWD_HASH = "$2a$10$abcdefghijklmnopqrstuvABCDEFGHIJKLMNOPQRSTUVWXYZ012345";
private static final String PAY_HASH = "$2a$10$zyxwvutsrqponmlkjihgfeDCBAHGFEDCBAZYXWVUTSRQPONMLKJIHG";
private User user() {
User user = new User();
user.setUserId(1);
user.setUsername("zhangsan");
user.setPhone("13800000009");
user.setPassword(PWD_HASH);
user.setPayPassword(PAY_HASH);
return user;
}
@Test
@DisplayName("序列化 User 时不输出任何密码字段")
void serializationHidesCredentials() throws Exception {
String json = new ObjectMapper().writeValueAsString(user());
assertFalse(json.contains("password"), "响应不应包含 password / payPassword 字段");
assertFalse(json.contains("payPassword"), "响应不应包含 payPassword 字段");
assertFalse(json.contains(PWD_HASH), "响应不应包含登录密码哈希");
assertFalse(json.contains(PAY_HASH), "响应不应包含支付密码哈希");
// 非敏感字段照常输出,避免误伤
assertTrue(json.contains("zhangsan"));
assertTrue(json.contains("13800000009"));
}
@Test
@DisplayName("反序列化仍能写入密码,注册/改密/批量导入不受影响")
void deserializationStillAcceptsCredentials() throws Exception {
String body = "{\"username\":\"lisi\",\"password\":\"" + PWD_HASH
+ "\",\"payPassword\":\"" + PAY_HASH + "\"}";
User parsed = new ObjectMapper().readValue(body, User.class);
assertEquals("lisi", parsed.getUsername());
assertEquals(PWD_HASH, parsed.getPassword());
assertEquals(PAY_HASH, parsed.getPayPassword());
}
@Test
@DisplayName("同步到 websopy 的 MQ 报文仍包含密码字段,行为与修复前一致")
void mqPayloadStillCarriesCredentials() {
RabbitTemplate rabbitTemplate = mock(RabbitTemplate.class);
MessageConverter messageConverter = mock(MessageConverter.class);
RabbitMQSyncProducer producer =
new RabbitMQSyncProducer(rabbitTemplate, messageConverter, new ObjectMapper());
producer.sendUserSyncMessage("websopy", "CREATE", user());
ArgumentCaptor<SyncMessage> captor = ArgumentCaptor.forClass(SyncMessage.class);
verify(rabbitTemplate).convertAndSend(
eq(RabbitMQConfig.SYNC_EXCHANGE), anyString(), captor.capture(),
any(CorrelationData.class));
Map<String, Object> data = captor.getValue().getData();
assertEquals(PWD_HASH, data.get("password"));
assertEquals(PAY_HASH, data.get("payPassword"));
assertEquals("zhangsan", data.get("username"));
}
}
@@ -0,0 +1,69 @@
package com.gxwebsoft.openplatform;
import com.gxwebsoft.common.system.entity.Order;
import com.gxwebsoft.openplatform.vo.OpenOrderVO;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 开放接口订单出参的字段裁剪与脱敏测试。
*
* @author WebSoft
*/
class OpenOrderVOTest {
private Order order() {
Order order = new Order();
order.setOrderId(1);
order.setOrderNo("1234567890");
order.setPhone("13800000009");
order.setRealName("张三");
order.setComments("内部备注");
return order;
}
@Test
@DisplayName("默认对手机号脱敏")
void masksPhoneByDefault() {
List<OpenOrderVO> list = OpenOrderVO.from(List.of(order()), true);
assertEquals(1, list.size());
assertEquals("138****0009", list.get(0).getPhone());
assertEquals("张三", list.get(0).getRealName());
}
@Test
@DisplayName("关闭脱敏后返回原号码")
void keepsPhoneWhenMaskingDisabled() {
List<OpenOrderVO> list = OpenOrderVO.from(List.of(order()), false);
assertEquals("13800000009", list.get(0).getPhone());
}
@Test
@DisplayName("手机号异常长度时原样返回,不抛异常")
void handlesShortPhone() {
Order order = order();
order.setPhone("123");
assertEquals("123", OpenOrderVO.from(List.of(order), true).get(0).getPhone());
order.setPhone(null);
assertNull(OpenOrderVO.from(List.of(order), true).get(0).getPhone());
}
@Test
@DisplayName("空列表返回空集合而不是 null")
void handlesEmptyList() {
assertTrue(OpenOrderVO.from(null, true).isEmpty());
assertTrue(OpenOrderVO.from(List.of(), true).isEmpty());
assertNotNull(OpenOrderVO.from(List.of(order()), true).get(0));
}
}
@@ -0,0 +1,99 @@
package com.gxwebsoft.openplatform;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.openplatform.vo.OpenUserVO;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
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.assertTrue;
/**
* 开放接口用户出参测试。
*
* <p>重点是<b>不能泄露密码</b>User 实体没有 @JsonIgnoreUserMapper 又用 SELECT a.*
* 所以这里直接序列化结果做断言,防止以后有人图省事把实体返回出去。</p>
*
* @author WebSoft
*/
class OpenUserVOTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private User user() {
User user = new User();
user.setUserId(1);
user.setUsername("zhangsan");
user.setNickname("张三");
user.setPhone("13800000009");
user.setEmail("zhangsan@example.com");
// 敏感字段:绝不能出现在开放接口响应里
user.setPassword("$2a$10$abcdefghijklmnopqrstuv");
user.setPayPassword("$2a$10$paypaypaypaypaypaypayp");
user.setIdCard("450102199001011234");
return user;
}
@Test
@DisplayName("序列化结果里不含密码、支付密码、身份证号")
void neverExposesCredentials() throws Exception {
List<OpenUserVO> list = OpenUserVO.from(List.of(user()), true);
String json = MAPPER.writeValueAsString(list);
assertFalse(json.contains("password"), "响应不应包含 password / payPassword 字段");
assertFalse(json.contains("payPassword"), "响应不应包含 payPassword 字段");
assertFalse(json.contains("$2a$10$"), "响应不应包含密码哈希");
assertFalse(json.contains("idCard"), "响应不应包含身份证号字段");
assertTrue(json.contains("zhangsan"));
}
@Test
@DisplayName("手机号与邮箱按开关脱敏")
void masksContactInfo() {
OpenUserVO masked = OpenUserVO.from(List.of(user()), true).get(0);
assertEquals("138****0009", masked.getPhone());
assertEquals("z***@example.com", masked.getEmail());
OpenUserVO plain = OpenUserVO.from(List.of(user()), false).get(0);
assertEquals("13800000009", plain.getPhone());
assertEquals("zhangsan@example.com", plain.getEmail());
}
@Test
@DisplayName("异常联系方式的兜底处理")
void handlesUnusualContactInfo() {
User user = user();
user.setPhone("123");
user.setEmail("a@b.com");
OpenUserVO vo = OpenUserVO.from(List.of(user), true).get(0);
assertEquals("123", vo.getPhone());
assertEquals("a***@b.com", vo.getEmail());
// 非法格式不臆造,原样返回
user.setEmail("@example.com");
assertEquals("@example.com", OpenUserVO.from(List.of(user), true).get(0).getEmail());
user.setEmail("no-at-sign");
assertEquals("no-at-sign", OpenUserVO.from(List.of(user), true).get(0).getEmail());
user.setPhone(null);
user.setEmail(null);
vo = OpenUserVO.from(List.of(user), true).get(0);
assertNull(vo.getPhone());
assertNull(vo.getEmail());
}
@Test
@DisplayName("空列表返回空集合")
void handlesEmptyList() {
assertTrue(OpenUserVO.from(null, true).isEmpty());
assertTrue(OpenUserVO.from(List.of(), true).isEmpty());
}
}