From 86e73b68b7007582b218511f1ee1c8aa97e40c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Tue, 22 Sep 2026 08:40:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(user):=20=E4=BF=AE=E5=A4=8D=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E5=93=88=E5=B8=8C=E9=80=9A=E8=BF=87=E5=85=8D=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=8E=A5=E5=8F=A3=E6=9A=B4=E9=9C=B2=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 User 实体的 password 和 payPassword 字段添加@JsonProperty(WRITE_ONLY)注解 - 保证密码字段不再通过 HTTP 接口响应体序列化输出 - 保持反序列化能力,注册、改密及批量导入接口不受影响 - RabbitMQ 消息同步时显式补回密码字段,确保 MQ 报文不变 - 新增 UserCredentialSerializationTest,覆盖 HTTP 响应、反序列化和 MQ 同步报文场景 - 文档更新,说明已修复接口列表及仍需决策的免登录接口安全隐患 - 将 server-api 版本升级至 2.0 --- docs/OPEN_PLATFORM_INTEGRATION.md | 36 ++++++- pom.xml | 2 +- .../producer/impl/RabbitMQSyncProducer.java | 14 +++ .../gxwebsoft/common/system/entity/User.java | 12 +++ .../UserCredentialSerializationTest.java | 99 +++++++++++++++++++ 5 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/gxwebsoft/common/system/UserCredentialSerializationTest.java diff --git a/docs/OPEN_PLATFORM_INTEGRATION.md b/docs/OPEN_PLATFORM_INTEGRATION.md index 8f6e1e6..591b838 100644 --- a/docs/OPEN_PLATFORM_INTEGRATION.md +++ b/docs/OPEN_PLATFORM_INTEGRATION.md @@ -205,7 +205,7 @@ open-platform: > `SecurityConfig` 里 GET 的 `/**` 白名单摘掉,再按真实流量补显式白名单, > 影响面较大,建议单独排期。 -### 6.1 用户密码哈希可通过免登录接口读取(未修复,优先级高) +### 6.1 用户密码哈希可通过免登录接口读取(已修复) `GET /api/system/user/getByUserId/{userId}` 在 `SecurityConfig` 的 GET 白名单里, 实测**无需任何凭证**,只带一个 `tenantId` 请求头即可取到用户实体, @@ -220,15 +220,43 @@ curl -H 'tenantId: <任意租户ID>' 'https://server.websoft.top/api/system/user 且 `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. 如何新增一个开放接口 diff --git a/pom.xml b/pom.xml index 6f34d31..1a63160 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ com.gxwebsoft server-api - 1.0 + 2.0 server-api WebSoftApi project for Spring Boot diff --git a/src/main/java/com/gxwebsoft/common/mq/producer/impl/RabbitMQSyncProducer.java b/src/main/java/com/gxwebsoft/common/mq/producer/impl/RabbitMQSyncProducer.java index 5aff9ad..deeab26 100644 --- a/src/main/java/com/gxwebsoft/common/mq/producer/impl/RabbitMQSyncProducer.java +++ b/src/main/java/com/gxwebsoft/common/mq/producer/impl/RabbitMQSyncProducer.java @@ -4,6 +4,7 @@ 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.SyncMessageProducer; +import com.gxwebsoft.common.system.entity.User; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.core.Message; import org.springframework.amqp.core.MessageProperties; @@ -108,6 +109,19 @@ public class RabbitMQSyncProducer implements SyncMessageProducer, RabbitTemplate 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); sendSyncMessage(message); } catch (Exception e) { diff --git a/src/main/java/com/gxwebsoft/common/system/entity/User.java b/src/main/java/com/gxwebsoft/common/system/entity/User.java index a1dffae..d933563 100644 --- a/src/main/java/com/gxwebsoft/common/system/entity/User.java +++ b/src/main/java/com/gxwebsoft/common/system/entity/User.java @@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity; import cn.hutool.core.util.DesensitizedUtil; import com.baomidou.mybatisplus.annotation.*; 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 lombok.Data; @@ -40,7 +41,14 @@ public class User implements UserDetails { @Schema(description = "账号") private String username; + /** + * 登录密码(BCrypt 哈希)。 + * + *

只写不读:序列化时永远不输出,避免任何接口把密码哈希返回给调用方; + * 反序列化不受影响,注册 / 改密 / 批量导入仍可正常写入。

+ */ @Schema(description = "密码") + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String password; @Schema(description = "昵称") @@ -86,7 +94,11 @@ public class User implements UserDetails { @Schema(description = "特长") private String speciality; + /** + * 支付密码(BCrypt 哈希),与登录密码同样只写不读。 + */ @Schema(description = "支付密码") + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String payPassword; @Schema(description = "职务") diff --git a/src/test/java/com/gxwebsoft/common/system/UserCredentialSerializationTest.java b/src/test/java/com/gxwebsoft/common/system/UserCredentialSerializationTest.java new file mode 100644 index 0000000..49a4a91 --- /dev/null +++ b/src/test/java/com/gxwebsoft/common/system/UserCredentialSerializationTest.java @@ -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; + +/** + * 用户密码字段的序列化契约测试。 + * + *

背景:{@code User} 实体被十几个接口直接当响应体返回,其中 + * {@code GET /api/system/user/getByUserId/{id}}、{@code /withoutAuth} 等还免登录, + * 原先响应里带着 BCrypt 密码哈希。修复方式是把 password / payPassword 设为 WRITE_ONLY, + * 这里的三个用例分别锁住:HTTP 不再输出、反序列化仍可写入、MQ 同步报文保持原样。

+ * + * @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 captor = ArgumentCaptor.forClass(SyncMessage.class); + verify(rabbitTemplate).convertAndSend( + eq(RabbitMQConfig.SYNC_EXCHANGE), anyString(), captor.capture(), + any(CorrelationData.class)); + + Map data = captor.getValue().getData(); + assertEquals(PWD_HASH, data.get("password")); + assertEquals(PAY_HASH, data.get("payPassword")); + assertEquals("zhangsan", data.get("username")); + } + +}