fix(user): 修复密码哈希通过免登录接口暴露问题

- 在 User 实体的 password 和 payPassword 字段添加@JsonProperty(WRITE_ONLY)注解
- 保证密码字段不再通过 HTTP 接口响应体序列化输出
- 保持反序列化能力,注册、改密及批量导入接口不受影响
- RabbitMQ 消息同步时显式补回密码字段,确保 MQ 报文不变
- 新增 UserCredentialSerializationTest,覆盖 HTTP 响应、反序列化和 MQ 同步报文场景
- 文档更新,说明已修复接口列表及仍需决策的免登录接口安全隐患
- 将 server-api 版本升级至 2.0
This commit is contained in:
2026-09-22 08:40:21 +08:00
parent 0923635575
commit 86e73b68b7
5 changed files with 158 additions and 5 deletions
+32 -4
View File
@@ -205,7 +205,7 @@ open-platform:
> `SecurityConfig` 里 GET 的 `/**` 白名单摘掉,再按真实流量补显式白名单, > `SecurityConfig` 里 GET 的 `/**` 白名单摘掉,再按真实流量补显式白名单,
> 影响面较大,建议单独排期。 > 影响面较大,建议单独排期。
### 6.1 用户密码哈希可通过免登录接口读取(修复,优先级高 ### 6.1 用户密码哈希可通过免登录接口读取(修复)
`GET /api/system/user/getByUserId/{userId}``SecurityConfig` 的 GET 白名单里, `GET /api/system/user/getByUserId/{userId}``SecurityConfig` 的 GET 白名单里,
实测**无需任何凭证**,只带一个 `tenantId` 请求头即可取到用户实体, 实测**无需任何凭证**,只带一个 `tenantId` 请求头即可取到用户实体,
@@ -220,15 +220,43 @@ curl -H 'tenantId: <任意租户ID>' 'https://server.websoft.top/api/system/user
`UserMapper` 使用 `SELECT a.*`;同时该接口无 `@PreAuthorize` 又落在 GET 白名单里。 `UserMapper` 使用 `SELECT a.*`;同时该接口无 `@PreAuthorize` 又落在 GET 白名单里。
`GET /api/system/user/withoutAuth` 属于同一族(免登录 + 返回实体)。 `GET /api/system/user/withoutAuth` 属于同一族(免登录 + 返回实体)。
最小修复是在实体上把这两个字段设为只写: 修复方式是在实体上把这两个字段设为只写:
```java ```java
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY) @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password; 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. 如何新增一个开放接口 ## 7. 如何新增一个开放接口
+1 -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>
@@ -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) {
@@ -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 = "职务")
@@ -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"));
}
}