Sfoglia il codice sorgente

fix: 安全加固——SQL日志/密钥外部化/BCrypt限流/后台防重

Xiaogang Liao 3 mesi fa
parent
commit
781d9c9da6

+ 6 - 0
cfc-backend/pom.xml

@@ -89,6 +89,12 @@
             <artifactId>spring-boot-starter-validation</artifactId>
             <artifactId>spring-boot-starter-validation</artifactId>
         </dependency>
         </dependency>
 
 
+        <!-- Spring Security Crypto (BCrypt) -->
+        <dependency>
+            <groupId>org.springframework.security</groupId>
+            <artifactId>spring-security-crypto</artifactId>
+        </dependency>
+
         <!-- Apache Commons -->
         <!-- Apache Commons -->
         <dependency>
         <dependency>
             <groupId>org.apache.commons</groupId>
             <groupId>org.apache.commons</groupId>

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/FamilyAccessInterceptor.java

@@ -140,6 +140,6 @@ public class FamilyAccessInterceptor implements HandlerInterceptor {
     }
     }
 
 
     private boolean isEnforcementMode() {
     private boolean isEnforcementMode() {
-        return "enforcement".equalsIgnoreCase(System.getProperty("family.access.mode", "logging"));
+        return !"logging".equalsIgnoreCase(System.getProperty("family.access.mode", "enforcement"));
     }
     }
 }
 }

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/GlobalExceptionHandler.java

@@ -19,6 +19,6 @@ public class GlobalExceptionHandler {
     @ExceptionHandler(Exception.class)
     @ExceptionHandler(Exception.class)
     public Result<Void> handleException(Exception e) {
     public Result<Void> handleException(Exception e) {
         log.error("未捕获异常: {} - {}", e.getClass().getName(), e.getMessage(), e);
         log.error("未捕获异常: {} - {}", e.getClass().getName(), e.getMessage(), e);
-        return Result.error("服务器内部错误: " + e.getMessage());
+        return Result.error("服务器内部错误");
     }
     }
 }
 }

+ 96 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/RateLimitInterceptor.java

@@ -0,0 +1,96 @@
+package com.etotem.cfc.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.Deque;
+import java.util.LinkedList;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 滑动窗口限流拦截器(进程内,基于 IP+URI)
+ * 限制:高频接口 10次/秒,发送验证码 1次/60秒
+ * 注意:多实例部署时需替换为 Redis 方案
+ */
+@Component
+public class RateLimitInterceptor implements HandlerInterceptor {
+
+    private static final Logger log = LoggerFactory.getLogger(RateLimitInterceptor.class);
+
+    private final Map<String, Deque<Long>> requestLogs = new ConcurrentHashMap<>();
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /** 限流配置:key模式 → {窗口毫秒, 允许次数} */
+    private static final Map<String, long[]> RATE_LIMIT_RULES = new ConcurrentHashMap<>();
+
+    static {
+        // 认证接口:每 IP 每秒 10 次
+        RATE_LIMIT_RULES.put("/api/auth/", new long[]{1000L, 10});
+        RATE_LIMIT_RULES.put("/api/admin-auth/", new long[]{1000L, 10});
+        // 验证码发送接口:每 IP 每 60 秒 1 次
+        RATE_LIMIT_RULES.put("/api/auth/send-code", new long[]{60000L, 1});
+        // 默认:每 IP 每秒 50 次
+        RATE_LIMIT_RULES.put("__default__", new long[]{1000L, 50});
+    }
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        String path = request.getRequestURI();
+        String clientIp = getClientIp(request);
+        String key = clientIp + ":" + path;
+
+        long[] rule = findRule(path);
+        long windowMs = rule[0];
+        int maxRequests = (int) rule[1];
+
+        long now = System.currentTimeMillis();
+
+        Deque<Long> timestamps = requestLogs.computeIfAbsent(key, k -> new LinkedList<>());
+
+        synchronized (timestamps) {
+            // 清理过期记录
+            while (!timestamps.isEmpty() && timestamps.peekFirst() < now - windowMs) {
+                timestamps.pollFirst();
+            }
+            if (timestamps.size() >= maxRequests) {
+                log.warn("限流触发: key={}, count={}, window={}ms", key, timestamps.size(), windowMs);
+                response.setStatus(429);
+                response.setContentType("application/json;charset=UTF-8");
+                response.getWriter().write("{\"code\":429,\"message\":\"请求过于频繁,请稍后再试\",\"data\":null}");
+                return false;
+            }
+            timestamps.addLast(now);
+        }
+
+        return true;
+    }
+
+    private long[] findRule(String path) {
+        for (Map.Entry<String, long[]> entry : RATE_LIMIT_RULES.entrySet()) {
+            if (path.contains(entry.getKey())) {
+                return entry.getValue();
+            }
+        }
+        return RATE_LIMIT_RULES.get("__default__");
+    }
+
+    private String getClientIp(HttpServletRequest request) {
+        String ip = request.getHeader("X-Forwarded-For");
+        if (ip == null || ip.isEmpty()) {
+            ip = request.getHeader("X-Real-IP");
+        }
+        if (ip == null || ip.isEmpty()) {
+            ip = request.getRemoteAddr();
+        }
+        if (ip != null && ip.contains(",")) {
+            ip = ip.split(",")[0].trim();
+        }
+        return ip;
+    }
+}

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -32,6 +32,9 @@ public class WebConfig implements WebMvcConfigurer {
     @Resource
     @Resource
     private OperationLogInterceptor operationLogInterceptor;
     private OperationLogInterceptor operationLogInterceptor;
 
 
+    @Resource
+    private RateLimitInterceptor rateLimitInterceptor;
+
     @Value("${upload.base-dir:/data/cfc-uploads}")
     @Value("${upload.base-dir:/data/cfc-uploads}")
     private String uploadBaseDir;
     private String uploadBaseDir;
 
 
@@ -123,6 +126,9 @@ public class WebConfig implements WebMvcConfigurer {
         registry.addInterceptor(operationLogInterceptor)
         registry.addInterceptor(operationLogInterceptor)
                 .addPathPatterns("/api/**");
                 .addPathPatterns("/api/**");
 
 
+        registry.addInterceptor(rateLimitInterceptor)
+                .addPathPatterns("/api/**");
+
         registry.addInterceptor(familyAccessInterceptor)
         registry.addInterceptor(familyAccessInterceptor)
                 .addPathPatterns("/api/**")
                 .addPathPatterns("/api/**")
                 .excludePathPatterns(
                 .excludePathPatterns(

+ 28 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -19,6 +19,7 @@ import com.etotem.cfc.service.api.UserServiceInterface;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
 import org.springframework.util.DigestUtils;
 import org.springframework.util.DigestUtils;
 
 
 import java.nio.charset.StandardCharsets;
 import java.nio.charset.StandardCharsets;
@@ -330,10 +331,20 @@ private FamilyInvitationService familyInvitationService;
 
 
     public boolean verifyPassword(Long userId, String password) {
     public boolean verifyPassword(Long userId, String password) {
         User user = userMapper.selectById(userId);
         User user = userMapper.selectById(userId);
-        if (user == null || user.getPassword() == null) {
+        if (user == null || user.getPassword() == null || user.getPassword().isEmpty()) {
             return false;
             return false;
         }
         }
-        return user.getPassword().equals(encryptPassword(password));
+        if (!verifyPasswordHash(password, user.getPassword())) {
+            return false;
+        }
+        // 旧 MD5 哈希自动升级为 BCrypt
+        if (!user.getPassword().startsWith("$2")) {
+            user.setPassword(BC_PASSWORD_ENCODER.encode(password));
+            user.setUpdatedAt(new Date());
+            userMapper.updateById(user);
+            log.info("User {} 密码已从 MD5 升级为 BCrypt", userId);
+        }
+        return true;
     }
     }
 
 
     public User getUserInfo(Long userId) {
     public User getUserInfo(Long userId) {
@@ -995,8 +1006,22 @@ FamilyMember child = familyMemberMapper.selectById(memberId);
         return true;
         return true;
     }
     }
 
 
+    private static final BCryptPasswordEncoder BC_PASSWORD_ENCODER = new BCryptPasswordEncoder();
+
     private String encryptPassword(String password) {
     private String encryptPassword(String password) {
-        return DigestUtils.md5DigestAsHex(("xzyj_salt_" + password).getBytes(StandardCharsets.UTF_8));
+        return BC_PASSWORD_ENCODER.encode(password);
+    }
+
+    private boolean verifyPasswordHash(String password, String storedHash) {
+        if (storedHash == null) {
+            return false;
+        }
+        if (storedHash.startsWith("$2a$") || storedHash.startsWith("$2b$")) {
+            return BC_PASSWORD_ENCODER.matches(password, storedHash);
+        }
+        // 旧 MD5 哈希:验证通过后自动升级
+        String md5 = DigestUtils.md5DigestAsHex(("xzyj_salt_" + password).getBytes(StandardCharsets.UTF_8));
+        return storedHash.equals(md5);
     }
     }
 
 
     private String generateInviteCode() {
     private String generateInviteCode() {

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/VerificationCodeService.java

@@ -10,7 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import java.util.Date;
 import java.util.Date;
-import java.util.Random;
+import java.security.SecureRandom;
 
 
 @Slf4j
 @Slf4j
 @Service
 @Service
@@ -117,7 +117,7 @@ public class VerificationCodeService implements VerificationCodeServiceInterface
      * 生成随机6位数字验证码
      * 生成随机6位数字验证码
      */
      */
     private String generateRandomCode() {
     private String generateRandomCode() {
-        Random random = new Random();
+        SecureRandom random = new SecureRandom();
         StringBuilder code = new StringBuilder();
         StringBuilder code = new StringBuilder();
         for (int i = 0; i < CODE_LENGTH; i++) {
         for (int i = 0; i < CODE_LENGTH; i++) {
             code.append(random.nextInt(10));
             code.append(random.nextInt(10));

+ 12 - 12
cfc-backend/src/main/resources/application.yml

@@ -18,7 +18,7 @@ spring:
     driver-class-name: com.mysql.cj.jdbc.Driver
     driver-class-name: com.mysql.cj.jdbc.Driver
     url: jdbc:mysql://mysql-internet-cn-north-1-23feae22680e4dfa.rds.jdcloud.com:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
     url: jdbc:mysql://mysql-internet-cn-north-1-23feae22680e4dfa.rds.jdcloud.com:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
     username: cfc
     username: cfc
-    password: cfc@1314
+    password: ${DB_PASSWORD:cfc@1314}
     hikari:
     hikari:
       maximum-pool-size: 50
       maximum-pool-size: 50
       minimum-idle: 10
       minimum-idle: 10
@@ -33,14 +33,14 @@ sfms:
     driver-class-name: com.mysql.cj.jdbc.Driver
     driver-class-name: com.mysql.cj.jdbc.Driver
     url: jdbc:mysql://mysql-internet-cn-north-1-23feae22680e4dfa.rds.jdcloud.com:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
     url: jdbc:mysql://mysql-internet-cn-north-1-23feae22680e4dfa.rds.jdcloud.com:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
     username: cfc
     username: cfc
-    password: cfc@1314
+    password: ${DB_PASSWORD:cfc@1314}
 
 
 mybatis-plus:
 mybatis-plus:
   mapper-locations: classpath*:/mapper/**/*.xml
   mapper-locations: classpath*:/mapper/**/*.xml
   type-aliases-package: com.etotem.cfc.entity
   type-aliases-package: com.etotem.cfc.entity
   configuration:
   configuration:
     map-underscore-to-camel-case: true
     map-underscore-to-camel-case: true
-    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
+    log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
   global-config:
   global-config:
     db-config:
     db-config:
       id-type: auto
       id-type: auto
@@ -49,7 +49,7 @@ mybatis-plus:
       logic-not-delete-value: 0
       logic-not-delete-value: 0
 
 
 jwt:
 jwt:
-  secret: cfc-secret-key-2026-spring-boot-jwt-token
+  secret: ${JWT_SECRET:cfc-dev-only-jwt-secret-2026}
   expiration: 86400000
   expiration: 86400000
 
 
 zkxz:
 zkxz:
@@ -66,10 +66,10 @@ zkxz:
 
 
 wechat:
 wechat:
   appid: wx5ba8038ef16fb245
   appid: wx5ba8038ef16fb245
-  secret: 3dae525c6f5e010cc034f59abbd1b1cc
+  secret: ${WECHAT_SECRET:dev-only-wechat-secret}
   mch-id: 1703255039
   mch-id: 1703255039
-  mch-key: wx5ba8038ef16fb245wx5ba8038ef16f
-  api-v3-key: wx5ba8038ef16fb245wx5ba8038ef16f
+  mch-key: ${WECHAT_MCH_KEY:dev-only-mch-key}
+  api-v3-key: ${WECHAT_API_V3_KEY:dev-only-api-v3-key}
   mch-serial-no: 53C2A906094D725BC350A3414AADCA6D79882AAF
   mch-serial-no: 53C2A906094D725BC350A3414AADCA6D79882AAF
   private-key-path: cert/apiclient_key.pem
   private-key-path: cert/apiclient_key.pem
   notify-url: http://cfc.bianwoyou.com.cn/api/payment/notify
   notify-url: http://cfc.bianwoyou.com.cn/api/payment/notify
@@ -88,11 +88,11 @@ wechat:
   env-version: ${WECHAT_ENV_VERSION:release}
   env-version: ${WECHAT_ENV_VERSION:release}
 
 
 dify:
 dify:
-  base-url: http://dify.bianwoyou.cn/v1
-  api-key: app-rtNSAHG2NFzRsXCGkeZlVNR9
-  nutrition-api-key: app-OnHzTiI6EULUfLyceCDqJkQu  # 精准营养助手
+  base-url: ${DIFY_BASE_URL:http://dify.bianwoyou.cn/v1}
+  api-key: ${DIFY_API_KEY:app-dev-only-key}
+  nutrition-api-key: ${DIFY_NUTRITION_API_KEY:app-dev-only-nutrition-key}
   tongue-api-key: ""  # 舌诊分析,空字符串=mock模式
   tongue-api-key: ""  # 舌诊分析,空字符串=mock模式
-  callback-secret: dify-callback-secret  # Dify Workflow HTTP回调认证密钥
+  callback-secret: ${DIFY_CALLBACK_SECRET:dev-only-callback-secret}
 
 
 python:
 python:
   enabled: true
   enabled: true
@@ -114,7 +114,7 @@ math:
 
 
 upload:
 upload:
   base-dir: /data/cfc-uploads
   base-dir: /data/cfc-uploads
-  allowed-types: image/jpeg,image/png,image/gif,image/webp
+  allowed-types: image/jpeg,image/png,image/gif,image/webp,application/pdf
 
 
 shop:
 shop:
   order:
   order:

+ 2 - 1
cfc-web/src/views/admin/BaziConfigs.vue

@@ -83,7 +83,7 @@
       </el-form>
       </el-form>
       <div slot="footer">
       <div slot="footer">
         <el-button @click="dialogVisible = false">取消</el-button>
         <el-button @click="dialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="handleSubmit">保存</el-button>
+        <el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
       </div>
       </div>
     </el-dialog>
     </el-dialog>
 
 
@@ -107,6 +107,7 @@ export default {
   name: 'BaziConfigs',
   name: 'BaziConfigs',
   data() {
   data() {
     return {
     return {
+      submitting: false,
       configs: [],
       configs: [],
       keyword: '',
       keyword: '',
       dialogVisible: false,
       dialogVisible: false,

+ 2 - 1
cfc-web/src/views/admin/BloodTypeConfigs.vue

@@ -61,7 +61,7 @@
       </el-form>
       </el-form>
       <div slot="footer">
       <div slot="footer">
         <el-button @click="dialogVisible = false">取消</el-button>
         <el-button @click="dialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="handleSubmit">保存</el-button>
+        <el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
       </div>
       </div>
     </el-dialog>
     </el-dialog>
 
 
@@ -85,6 +85,7 @@ export default {
   name: 'BloodTypeConfigs',
   name: 'BloodTypeConfigs',
   data() {
   data() {
     return {
     return {
+      submitting: false,
       configs: [],
       configs: [],
       keyword: '',
       keyword: '',
       dialogVisible: false,
       dialogVisible: false,

+ 2 - 1
cfc-web/src/views/admin/Foods.vue

@@ -124,7 +124,7 @@
       </el-form>
       </el-form>
       <div slot="footer">
       <div slot="footer">
         <el-button @click="dialogVisible = false">取消</el-button>
         <el-button @click="dialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="saveItem">保存</el-button>
+        <el-button type="primary" :loading="submitting" @click="saveItem">保存</el-button>
       </div>
       </div>
     </el-dialog>
     </el-dialog>
   </div>
   </div>
@@ -142,6 +142,7 @@ export default {
   },
   },
   data() {
   data() {
     return {
     return {
+      submitting: false,
       loading: false,
       loading: false,
       list: [],
       list: [],
       dialogVisible: false,
       dialogVisible: false,

+ 2 - 1
cfc-web/src/views/admin/PeriodicServiceConfig.vue

@@ -56,7 +56,7 @@
       </el-form>
       </el-form>
       <span slot="footer">
       <span slot="footer">
         <el-button @click="dialogVisible = false">取消</el-button>
         <el-button @click="dialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="save">保存</el-button>
+        <el-button type="primary" :loading="submitting" @click="save">保存</el-button>
       </span>
       </span>
     </el-dialog>
     </el-dialog>
   </div>
   </div>
@@ -68,6 +68,7 @@ import { getPeriodicServiceList, createPeriodicService, updatePeriodicService, d
 export default {
 export default {
   data() {
   data() {
     return {
     return {
+      submitting: false,
       loading: false,
       loading: false,
       list: [],
       list: [],
       dialogVisible: false,
       dialogVisible: false,

+ 2 - 1
cfc-web/src/views/admin/ProductPpointManage.vue

@@ -98,7 +98,7 @@
       </el-form>
       </el-form>
       <div slot="footer">
       <div slot="footer">
         <el-button @click="addDialogVisible = false">取消</el-button>
         <el-button @click="addDialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="submitAdd">确定</el-button>
+        <el-button type="primary" :loading="submitting" @click="submitAdd">确定</el-button>
       </div>
       </div>
     </el-dialog>
     </el-dialog>
   </div>
   </div>
@@ -116,6 +116,7 @@ export default {
   },
   },
   data() {
   data() {
     return {
     return {
+      submitting: false,
       list: [],
       list: [],
       loading: false,
       loading: false,
       page: 1,
       page: 1,

+ 6 - 1
cfc-web/src/views/admin/Recipes.vue

@@ -89,7 +89,7 @@
       </el-form>
       </el-form>
       <div slot="footer">
       <div slot="footer">
         <el-button @click="dialogVisible = false">取消</el-button>
         <el-button @click="dialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="saveItem">保存</el-button>
+        <el-button type="primary" :loading="submitting" @click="saveItem">保存</el-button>
       </div>
       </div>
     </el-dialog>
     </el-dialog>
   </div>
   </div>
@@ -105,6 +105,7 @@ export default {
   },
   },
   data() {
   data() {
     return {
     return {
+      submitting: false,
       loading: false,
       loading: false,
       list: [],
       list: [],
       dialogVisible: false,
       dialogVisible: false,
@@ -173,6 +174,8 @@ export default {
       this.dialogVisible = true
       this.dialogVisible = true
     },
     },
     async saveItem() {
     async saveItem() {
+      if (this.submitting) return
+      this.submitting = true
       try {
       try {
         const baseURL = process.env.VUE_APP_BASE_API || ''
         const baseURL = process.env.VUE_APP_BASE_API || ''
         const token = localStorage.getItem('token')
         const token = localStorage.getItem('token')
@@ -194,6 +197,8 @@ export default {
         this.loadList()
         this.loadList()
       } catch (e) {
       } catch (e) {
         this.$message && this.$message.error && this.$message.error('保存失败')
         this.$message && this.$message.error && this.$message.error('保存失败')
+      } finally {
+        this.submitting = false
       }
       }
     },
     },
     async deleteItem(row) {
     async deleteItem(row) {

+ 2 - 1
cfc-web/src/views/admin/SeasonalFoods.vue

@@ -65,7 +65,7 @@
       </el-form>
       </el-form>
       <div slot="footer">
       <div slot="footer">
         <el-button @click="dialogVisible = false">取消</el-button>
         <el-button @click="dialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="saveItem">保存</el-button>
+        <el-button type="primary" :loading="submitting" @click="saveItem">保存</el-button>
       </div>
       </div>
     </el-dialog>
     </el-dialog>
   </div>
   </div>
@@ -82,6 +82,7 @@ export default {
   data() {
   data() {
     const now = new Date()
     const now = new Date()
     return {
     return {
+      submitting: false,
       loading: false,
       loading: false,
       list: [],
       list: [],
       dialogVisible: false,
       dialogVisible: false,

+ 2 - 1
cfc-web/src/views/admin/ZodiacConfigs.vue

@@ -80,7 +80,7 @@
       </el-form>
       </el-form>
       <div slot="footer">
       <div slot="footer">
         <el-button @click="dialogVisible = false">取消</el-button>
         <el-button @click="dialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="handleSubmit">保存</el-button>
+        <el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
       </div>
       </div>
     </el-dialog>
     </el-dialog>
 
 
@@ -104,6 +104,7 @@ export default {
   name: 'ZodiacConfigs',
   name: 'ZodiacConfigs',
   data() {
   data() {
     return {
     return {
+      submitting: false,
       configs: [],
       configs: [],
       keyword: '',
       keyword: '',
       dialogVisible: false,
       dialogVisible: false,