fix(user): 修复密码哈希通过免登录接口暴露问题
- 在 User 实体的 password 和 payPassword 字段添加@JsonProperty(WRITE_ONLY)注解 - 保证密码字段不再通过 HTTP 接口响应体序列化输出 - 保持反序列化能力,注册、改密及批量导入接口不受影响 - RabbitMQ 消息同步时显式补回密码字段,确保 MQ 报文不变 - 新增 UserCredentialSerializationTest,覆盖 HTTP 响应、反序列化和 MQ 同步报文场景 - 文档更新,说明已修复接口列表及仍需决策的免登录接口安全隐患 - 将 server-api 版本升级至 2.0
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 哈希)。
|
||||
*
|
||||
* <p>只写不读:序列化时永远不输出,避免任何接口把密码哈希返回给调用方;
|
||||
* 反序列化不受影响,注册 / 改密 / 批量导入仍可正常写入。</p>
|
||||
*/
|
||||
@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 = "职务")
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user