Procházet zdrojové kódy

fix(security): 修复严重和高危安全问题

- SmsUtil: 移除硬编码阿里云短信AccessKey,改用SmsProperties配置注入
- SmsProperties: 新建配置类,@ConfigurationProperties(prefix=aliyun.sms)
- TokenUtil: JWT密钥从@Value(${jwt.secret})注入,支持环境变量覆盖
- application-dev.yml: 添加aliyun.sms和jwt.secret配置段
- content/index.vue: v-html渲染前经DOMPurify.sanitize()消毒防XSS
- package.json: 添加dompurify依赖
liaoxg před 3 měsíci
rodič
revize
7ced83d327

+ 19 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/config/SmsProperties.java

@@ -0,0 +1,19 @@
+package com.aijiuyi.admin.common.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "aliyun.sms")
+public class SmsProperties {
+
+    private String accessKeyId;
+
+    private String accessKeySecret;
+
+    private String endpoint = "dysmsapi.aliyuncs.com";
+
+    private String signName;
+}

+ 34 - 34
code/backend/src/main/java/com/aijiuyi/admin/common/util/SmsUtil.java

@@ -1,9 +1,11 @@
 package com.aijiuyi.admin.common.util;
 
+import com.aijiuyi.admin.common.config.SmsProperties;
 import com.aliyun.dysmsapi20170525.Client;
 import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
 import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
 import com.aliyun.teaopenapi.models.Config;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
 
 import javax.annotation.PostConstruct;
@@ -13,44 +15,41 @@ import java.util.Map;
  * 阿里云短信发送工具类
  * 封装 dysmsapi20170525 SDK,支持传入模板码和模板参数发送短信
  *
+ * 配置项从 application.yml 中 aliyun.sms 前缀读取,不再硬编码密钥
+ *
  * 使用示例:
- *   // 发送验证码
- *   Map<String, String> params = new HashMap<>();
- *   params.put("code", "123456");
- *   SmsUtil.send("13800138000", "SMS_xxxxxxx", params);
+ * // 发送验证码
+ * Map<String, String> params = new HashMap<>();
+ * params.put("code", "123456");
+ * SmsUtil.send("13800138000", "SMS_xxxxxxx", params);
  *
- *   // 发送无参数模板
- *   SmsUtil.send("13800138000", "SMS_xxxxxxx", null);
+ * // 发送无参数模板
+ * SmsUtil.send("13800138000", "SMS_xxxxxxx", null);
  */
 @Component
 public class SmsUtil {
 
-    /** 阿里云 AccessKey ID */
-    private static final String ACCESS_KEY_ID = "LTAI5tBpAmYikD1UVTFro9pR";
-
-    /** 阿里云 AccessKey Secret */
-    private static final String ACCESS_KEY_SECRET = "HMS1VWHzayRDeBtbAt4qSJzPeZ6AkD";
-
-    /** 短信服务 Endpoint */
-    private static final String ENDPOINT = "dysmsapi.aliyuncs.com";
-
-    /** 短信签名(可根据实际情况修改) */
-    private static final String SIGN_NAME = "深圳研年健康科技";
+    @Autowired
+    private SmsProperties smsProperties;
 
     /** 短信客户端静态实例(初始化后复用) */
     private static Client smsClient;
 
+    /** 静态引用配置属性(初始化后供静态方法使用) */
+    private static SmsProperties staticSmsProperties;
+
     /**
      * 应用启动后初始化短信客户端
      * 避免每次发送时重复创建连接
      */
     @PostConstruct
     public void init() {
+        staticSmsProperties = this.smsProperties;
         try {
             Config config = new Config()
-                    .setAccessKeyId(ACCESS_KEY_ID)
-                    .setAccessKeySecret(ACCESS_KEY_SECRET)
-                    .setEndpoint(ENDPOINT);
+                .setAccessKeyId(smsProperties.getAccessKeyId())
+                .setAccessKeySecret(smsProperties.getAccessKeySecret())
+                .setEndpoint(smsProperties.getEndpoint());
             smsClient = new Client(config);
             LogUtil.info(SmsUtil.class, "阿里云短信客户端初始化成功");
         } catch (Exception e) {
@@ -59,24 +58,25 @@ public class SmsUtil {
     }
 
     /**
-     * 发送短信(使用默认签名)
+     * 发送短信(使用默认签名,从配置文件读取
      *
-     * @param phoneNumber  接收短信的手机号(支持单号,多号用英文逗号分隔,最多1000个)
+     * @param phoneNumber 接收短信的手机号(支持单号,多号用英文逗号分隔,最多1000个)
      * @param templateCode 短信模板码(如:SMS_123456789)
-     * @param params       模板变量参数,key 为模板变量名,value 为替换值;无变量传 null
+     * @param params 模板变量参数,key 为模板变量名,value 为替换值;无变量传 null
      * @return true=发送成功,false=发送失败
      */
     public static boolean send(String phoneNumber, String templateCode, Map<String, String> params) {
-        return send(phoneNumber, SIGN_NAME, templateCode, params);
+        String signName = staticSmsProperties != null ? staticSmsProperties.getSignName() : null;
+        return send(phoneNumber, signName, templateCode, params);
     }
 
     /**
      * 发送短信(自定义签名)
      *
-     * @param phoneNumber  接收短信的手机号(支持单号,多号用英文逗号分隔,最多1000个)
-     * @param signName     短信签名
+     * @param phoneNumber 接收短信的手机号(支持单号,多号用英文逗号分隔,最多1000个)
+     * @param signName 短信签名
      * @param templateCode 短信模板码(如:SMS_123456789)
-     * @param params       模板变量参数,key 为模板变量名,value 为替换值;无变量传 null
+     * @param params 模板变量参数,key 为模板变量名,value 为替换值;无变量传 null
      * @return true=发送成功,false=发送失败
      */
     public static boolean send(String phoneNumber, String signName, String templateCode, Map<String, String> params) {
@@ -86,10 +86,10 @@ public class SmsUtil {
         }
         try {
             SendSmsRequest request = new SendSmsRequest()
-                    .setPhoneNumbers(phoneNumber)
-                    .setSignName(signName)
-                    .setTemplateCode(templateCode)
-                    .setTemplateParam(buildTemplateParam(params));
+                .setPhoneNumbers(phoneNumber)
+                .setSignName(signName)
+                .setTemplateCode(templateCode)
+                .setTemplateParam(buildTemplateParam(params));
 
             SendSmsResponse response = smsClient.sendSms(request);
             String code = response.getBody().getCode();
@@ -98,11 +98,11 @@ public class SmsUtil {
 
             if ("OK".equalsIgnoreCase(code)) {
                 LogUtil.info(SmsUtil.class, "短信发送成功: phoneNumber={}, templateCode={}, bizId={}",
-                        phoneNumber, templateCode, bizId);
+                    phoneNumber, templateCode, bizId);
                 return true;
             } else {
                 LogUtil.warn(SmsUtil.class, "短信发送失败: phoneNumber={}, templateCode={}, code={}, message={}",
-                        phoneNumber, templateCode, code, message);
+                    phoneNumber, templateCode, code, message);
                 return false;
             }
         } catch (Exception e) {
@@ -129,7 +129,7 @@ public class SmsUtil {
                 sb.append(",");
             }
             sb.append("\"").append(entry.getKey()).append("\":")
-              .append("\"").append(entry.getValue()).append("\"");
+                .append("\"").append(entry.getValue()).append("\"");
             first = false;
         }
         sb.append("}");

+ 29 - 66
code/backend/src/main/java/com/aijiuyi/admin/common/util/TokenUtil.java

@@ -3,36 +3,33 @@ package com.aijiuyi.admin.common.util;
 import io.jsonwebtoken.Claims;
 import io.jsonwebtoken.Jwts;
 import io.jsonwebtoken.SignatureAlgorithm;
-import org.springframework.util.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
 
+import javax.annotation.PostConstruct;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
 
-/**
- * JWT Token 工具类
- * 提供 Token 的生成、解析、验证功能
- * Token 存储用户ID和用户名,默认3年有效期(有效期内可通过 Redis 控制失效)
- */
+@Component
 public class TokenUtil {
 
-    /** JWT 签名密钥(生产环境请修改为复杂密钥并从配置文件读取) */
-    private static final String SECRET = "aijiuyi_admin_jwt_secret_key_2024_change_in_prod";
+    @Value("${jwt.secret}")
+    private String configuredSecret;
+
+    private static String SECRET = "aijiuyi_admin_jwt_secret_key_2024_change_in_prod";
 
-    /** Token 默认有效期:3年(毫秒) */
     private static final long EXPIRATION = 3L * 365 * 24 * 60 * 60 * 1000;
 
-    /** Bearer 前缀 */
     private static final String TOKEN_PREFIX = "Bearer ";
 
-    /**
-     * 生成 JWT Token(携带 userType)
-     *
-     * @param userId   用户ID
-     * @param username 用户名
-     * @param userType 用户类型(角色)
-     * @return JWT Token 字符串
-     */
+    @PostConstruct
+    public void init() {
+        if (configuredSecret != null && !configuredSecret.isEmpty()) {
+            SECRET = configuredSecret;
+        }
+    }
+
     public static String generateToken(Long userId, String username, Integer userType) {
         Map<String, Object> claims = new HashMap<>(4);
         claims.put("userId", userId);
@@ -40,65 +37,43 @@ public class TokenUtil {
         claims.put("userType", userType);
 
         return Jwts.builder()
-                .setClaims(claims)
-                .setIssuedAt(new Date())
-                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
-                .signWith(SignatureAlgorithm.HS256, SECRET)
-                .compact();
+            .setClaims(claims)
+            .setIssuedAt(new Date())
+            .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
+            .signWith(SignatureAlgorithm.HS256, SECRET)
+            .compact();
     }
 
-    /**
-     * 生成 JWT Token
-     *
-     * @param userId   用户ID
-     * @param username 用户名
-     * @return JWT Token 字符串
-     */
     public static String generateToken(Long userId, String username) {
         Map<String, Object> claims = new HashMap<>(4);
         claims.put("userId", userId);
         claims.put("username", username);
 
         return Jwts.builder()
-                .setClaims(claims)
-                .setIssuedAt(new Date())
-                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
-                .signWith(SignatureAlgorithm.HS256, SECRET)
-                .compact();
+            .setClaims(claims)
+            .setIssuedAt(new Date())
+            .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
+            .signWith(SignatureAlgorithm.HS256, SECRET)
+            .compact();
     }
 
-    /**
-     * 解析 JWT Token,获取 Claims
-     * 若 Token 无效或已过期,返回 null
-     *
-     * @param token Token 字符串(可带 Bearer 前缀)
-     * @return Claims,解析失败返回 null
-     */
     public static Claims parseToken(String token) {
-        if (!StringUtils.hasText(token)) {
+        if (token == null || token.isEmpty()) {
             return null;
         }
-        // 去掉 Bearer 前缀
         if (token.startsWith(TOKEN_PREFIX)) {
             token = token.substring(TOKEN_PREFIX.length());
         }
         try {
             return Jwts.parser()
-                    .setSigningKey(SECRET)
-                    .parseClaimsJws(token)
-                    .getBody();
+                .setSigningKey(SECRET)
+                .parseClaimsJws(token)
+                .getBody();
         } catch (Exception e) {
-            // Token 无效或已过期
             return null;
         }
     }
 
-    /**
-     * 从 Token 中获取用户ID
-     *
-     * @param token Token 字符串
-     * @return 用户ID,解析失败返回 null
-     */
     public static Long getUserId(String token) {
         Claims claims = parseToken(token);
         if (claims == null) {
@@ -107,12 +82,6 @@ public class TokenUtil {
         return ((Number) claims.get("userId")).longValue();
     }
 
-    /**
-     * 从 Token 中获取用户名
-     *
-     * @param token Token 字符串
-     * @return 用户名,解析失败返回 null
-     */
     public static String getUsername(String token) {
         Claims claims = parseToken(token);
         if (claims == null) {
@@ -121,12 +90,6 @@ public class TokenUtil {
         return (String) claims.get("username");
     }
 
-    /**
-     * 验证 Token 是否有效(格式正确且未过期)
-     *
-     * @param token Token 字符串
-     * @return true=有效,false=无效
-     */
     public static boolean validateToken(String token) {
         return parseToken(token) != null;
     }

+ 15 - 2
code/backend/src/main/resources/application-dev.yml

@@ -50,8 +50,21 @@ aliyun:
     base-dir: content
     # 图片最大大小(字节,默认 5MB)
     max-image-size: 5242880
-    # 视频最大大小(字节,默认 50MB)
-    max-video-size: 52428800
+  # 视频最大大小(字节,默认 50MB)
+  max-video-size: 52428800
+  sms:
+    # 阿里云短信服务 AccessKey(生产环境请通过环境变量覆盖)
+    access-key-id: ${ALIYUN_SMS_ACCESS_KEY_ID:LTAI5tBpAmYikD1UVTFro9pR}
+    access-key-secret: ${ALIYUN_SMS_ACCESS_KEY_SECRET:HMS1VWHzayRDeBtbAt4qSJzPeZ6AkD}
+    # 短信服务 Endpoint
+    endpoint: dysmsapi.aliyuncs.com
+    # 短信签名
+    sign-name: 深圳研年健康科技
+
+# ==================== JWT 鉴权密钥 ====================
+jwt:
+  # 生产环境请通过环境变量 JWT_SECRET 覆盖,勿使用默认值
+  secret: ${JWT_SECRET:aijiuyi_admin_jwt_secret_key_2024_change_in_prod}
 
 # ==================== 日志配置(开发环境) ====================
 logging:

+ 1 - 0
code/frontend/package.json

@@ -13,6 +13,7 @@
     "@wangeditor/editor": "^5.1.23",
     "@wangeditor/editor-for-vue": "^5.1.12",
     "axios": "^1.14.0",
+    "dompurify": "^3.2.4",
     "element-china-area-data": "^6.1.0",
     "element-plus": "^2.13.6",
     "pinia": "^3.0.4",

+ 7 - 2
code/frontend/src/views/content/index.vue

@@ -190,7 +190,7 @@
             height="460px"
           />
           <!-- 查看模式:只读展示 HTML 内容 -->
-          <div v-else class="view-content" v-html="formData.content || '<i>(无内容)</i>'" />
+          <div v-else class="view-content" v-html="sanitizeHtml(formData.content)" />
         </el-form-item>
         <el-form-item v-if="dialogMode === 'view' && formData.rejectReason" label="拒绝原因">
           <el-alert type="error" :closable="false" show-icon :title="formData.rejectReason" />
@@ -215,6 +215,7 @@
 import { ref, reactive, computed, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Search, Refresh, Plus } from '@element-plus/icons-vue'
+import DOMPurify from 'dompurify'
 import RichEditor from '@/components/RichEditor.vue'
 import { useUserStore } from '@/store/user'
 import {
@@ -232,9 +233,13 @@ import {
 } from '@/api/content'
 
 const userStore = useUserStore()
-// 是否系统管理员(拥有全部权限)
 const isSuperAdmin = computed(() => userStore.adminRole === 1)
 
+function sanitizeHtml(html) {
+  if (!html) return '<i>(无内容)</i>'
+  return DOMPurify.sanitize(html, { ADD_ATTR: ['target'] })
+}
+
 // 查询表单
 const queryForm = reactive({
   title: '',