Переглянути джерело

修复飞书自动看课请求头导致的漏洞

cgp 1 день тому
батько
коміт
83a82c8aa1

+ 135 - 0
fs-service/src/main/java/com/fs/course/utils/CourseSkipAuthTokenService.java

@@ -0,0 +1,135 @@
+package com.fs.course.utils;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+
+/**
+ * 飞书/企微看课 skip-auth 凭证服务
+ *
+ * <p>背景:看课 H5 无法走正常的 JWT 登录,只能通过请求头 {@code x-skip-auth} 传递用户身份。
+ * 历史实现直接传递明文 userId,存在任意用户可伪造、越权冒充的漏洞。
+ * 本服务改为服务端签发带签名的 token,避免信任客户端自报的明文 userId。</p>
+ *
+ * <p>token 格式:{@code userId:expireMillis:sign},其中
+ * {@code sign = HMAC-SHA256(secret, userId + ":" + expireMillis)},expireMillis 与看课链接的过期时刻对齐。</p>
+ *
+ * <p>兼容模式(mode=compat,默认):校验签名 token 失败时回退按明文 userId 解析,保证老前端业务不中断;
+ * 严格模式(mode=strict):仅接受签名 token。</p>
+ */
+@Component
+public class CourseSkipAuthTokenService {
+
+    private static final String HMAC_ALGORITHM = "HmacSHA256";
+    private static final String MODE_COMPAT = "compat";
+
+    /** 签名密钥,从配置读取 */
+    @Value("${fs.course.skip-auth.secret:}")
+    private String secret;
+
+    /** 校验模式:compat(兼容明文)/ strict(仅签名 token) */
+    @Value("${fs.course.skip-auth.mode:compat}")
+    private String mode;
+
+    /**
+     * 生成签名 token
+     *
+     * @param userId       用户 id
+     * @param expireMillis 过期时间戳(毫秒),通常取看课链接的过期时刻
+     * @return token 字符串
+     */
+    public String generateToken(Long userId, long expireMillis) {
+        if (userId == null) {
+            throw new IllegalArgumentException("userId 不能为空");
+        }
+        String data = userId + ":" + expireMillis;
+        return data + ":" + hmacSha256(data);
+    }
+
+    /**
+     * 解析并校验签名 token
+     *
+     * @param token token 字符串
+     * @return 校验通过返回 userId,否则返回 null
+     */
+    public Long parseToken(String token) {
+        if (token == null || token.isEmpty()) {
+            return null;
+        }
+        String[] parts = token.split(":");
+        if (parts.length != 3) {
+            return null;
+        }
+        String data = parts[0] + ":" + parts[1];
+        if (!constantTimeEquals(hmacSha256(data), parts[2])) {
+            return null;
+        }
+        long userId;
+        long expire;
+        try {
+            userId = Long.parseLong(parts[0]);
+            expire = Long.parseLong(parts[1]);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+        if (System.currentTimeMillis() > expire) {
+            return null;
+        }
+        return userId;
+    }
+
+    /**
+     * 解析 skip-auth 请求头:优先校验签名 token,compat 模式下回退明文 userId
+     *
+     * @param skipAuth 请求头 {@code x-skip-auth} 的值
+     * @return 合法则返回 userId,否则返回 null
+     */
+    public Long resolveUserId(String skipAuth) {
+        if (skipAuth == null || skipAuth.isEmpty()) {
+            return null;
+        }
+        Long userId = parseToken(skipAuth);
+        if (userId != null) {
+            return userId;
+        }
+        if (MODE_COMPAT.equalsIgnoreCase(mode)) {
+            try {
+                return Long.parseLong(skipAuth.trim());
+            } catch (NumberFormatException e) {
+                return null;
+            }
+        }
+        return null;
+    }
+
+    private String hmacSha256(String data) {
+        try {
+            Mac mac = Mac.getInstance(HMAC_ALGORITHM);
+            SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM);
+            mac.init(keySpec);
+            return toHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
+        } catch (Exception e) {
+            throw new RuntimeException("skip-auth 签名生成失败", e);
+        }
+    }
+
+    private boolean constantTimeEquals(String a, String b) {
+        if (a == null || b == null) {
+            return false;
+        }
+        return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private String toHex(byte[] bytes) {
+        StringBuilder sb = new StringBuilder(bytes.length * 2);
+        for (byte b : bytes) {
+            sb.append(Character.forDigit((b >> 4) & 0xF, 16));
+            sb.append(Character.forDigit(b & 0xF, 16));
+        }
+        return sb.toString();
+    }
+}

+ 10 - 0
fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java

@@ -9,6 +9,7 @@ import com.fs.course.domain.FsCourseLink;
 import com.fs.course.domain.FsUserCourseVideo;
 import com.fs.course.mapper.FsCourseLinkMapper;
 import com.fs.course.mapper.FsUserCourseVideoMapper;
+import com.fs.course.utils.CourseSkipAuthTokenService;
 import com.fs.feishu.domain.FeishuAccount;
 import com.fs.feishu.domain.FeishuLinkError;
 import com.fs.feishu.mapper.FeishuAccountMapper;
@@ -52,6 +53,8 @@ public class FeiShuService {
     private FeishuClientPool clientPool;
     @Autowired
     private FeishuDocApiService docApiService;
+    @Autowired
+    private CourseSkipAuthTokenService skipAuthTokenService;
 
     private static final String FEISHU_COMPANY_USER_KEY = "feishu:account_company_user:";
 
@@ -405,6 +408,13 @@ public class FeiShuService {
             String encodedJson = URLEncoder.encode(JSONUtil.toJsonStr(courseParam), "UTF-8");
             String fullUrl = config.getFeishuCourseDomain() +
                     "/feishuCourse/pages_course/video?course=" + encodedJson + "&userId=" + userId;
+            // 追加服务端签发的 skip-auth token(新前端使用,老前端仍读 userId 明文)
+            if (userId != null) {
+                long expireMillis = fsCourseLink.getUpdateTime() != null
+                        ? fsCourseLink.getUpdateTime().getTime()
+                        : System.currentTimeMillis() + 3L * 24 * 3600 * 1000;
+                fullUrl += "&skipToken=" + skipAuthTokenService.generateToken(userId, expireMillis);
+            }
             return URLEncoder.encode(fullUrl, "UTF-8");
         } catch (UnsupportedEncodingException e) {
             throw new CustomException("看课URL拼接失败", e);

+ 8 - 1
fs-user-app/src/main/java/com/fs/app/controller/AppBaseController.java

@@ -13,6 +13,7 @@ import com.fs.common.exception.CustomException;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.sql.SqlUtil;
+import com.fs.course.utils.CourseSkipAuthTokenService;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
 import io.jsonwebtoken.Claims;
@@ -27,6 +28,8 @@ public class AppBaseController {
 	public JwtUtils jwtUtils;
 	@Autowired
     public RedisCache redisCache;
+	@Autowired
+	public CourseSkipAuthTokenService skipAuthTokenService;
 
 	public String getUserId()
 	{
@@ -39,9 +42,13 @@ public class AppBaseController {
 			}
 		}
 		// 如果APPToken不存在或解析失败,从x-skip-auth获取[目前是飞书看课入口登录的用户id]
+		// 改为校验服务端签发的 token,compat 模式下回退明文,避免信任客户端自报 userId
 		String skipAuth = ServletUtils.getRequest().getHeader("x-skip-auth");
 		if (StringUtils.isNotEmpty(skipAuth)) {
-			return skipAuth;
+			Long userId = skipAuthTokenService.resolveUserId(skipAuth);
+			if (userId != null) {
+				return userId.toString();
+			}
 		}
 		return null;
 	}

+ 0 - 4
fs-user-app/src/main/java/com/fs/app/controller/course/CourseFsUserController.java

@@ -15,7 +15,6 @@ import com.fs.course.param.newfs.FsUserCourseVideoUParam;
 import com.fs.course.service.*;
 import com.fs.course.vo.FsUserCourseVideoH5VO;
 import com.fs.course.vo.newfs.FsUserCourseVideoLinkDetailsVO;
-import com.fs.feishu.service.FeiShuService;
 import com.fs.his.domain.FsUser;
 import com.fs.his.enums.FsUserOperationEnum;
 import io.swagger.annotations.Api;
@@ -47,9 +46,6 @@ public class CourseFsUserController extends AppBaseController {
     @Autowired
     private IFsCourseQuestionBankService questionBankService;
 
-    @Autowired
-    private FeiShuService feiShuService;
-
 
     @Login
     @ApiOperation("判断是否添加客服(是否关联销售)")

+ 7 - 8
fs-user-app/src/main/java/com/fs/app/controller/course/CourseQwController.java

@@ -379,15 +379,14 @@ public class CourseQwController extends AppBaseController {
     public void getErrMsg(@RequestParam("msg") String msg,
                           @RequestHeader Map<String, String> headers) {
         Long userId;
+        String uid = getUserId();
+        if (uid == null) {
+            throw new CustomException("用户未登录");
+        }
         try {
-            userId =Long.parseLong(getUserId());
-        }catch (NumberFormatException e){
-            //如果登录状态异常就从请求头获取userId
-            String skipAuth = headers.get("x-skip-auth");
-            if (skipAuth == null){
-                throw new CustomException("用户未登录");
-            }
-            userId = Long.parseLong(skipAuth);
+            userId = Long.parseLong(uid);
+        } catch (NumberFormatException e) {
+            throw new CustomException("用户未登录");
         }
         logger.error("zyp \n【看课中途报错】:{}","userId:"+userId+msg);
     }

+ 10 - 2
fs-user-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java

@@ -6,6 +6,7 @@ import com.fs.app.exception.FSException;
 import com.fs.app.utils.JwtUtils;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.StringUtils;
+import com.fs.course.utils.CourseSkipAuthTokenService;
 import io.jsonwebtoken.Claims;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.HttpStatus;
@@ -25,6 +26,8 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
     private JwtUtils jwtUtils;
     @Autowired
     RedisCache redisCache;
+    @Autowired
+    private CourseSkipAuthTokenService skipAuthTokenService;
     public static final String USER_KEY = "userId";
     private static final String SKIP_AUTH_HEADER = "X-Skip-Auth";
 
@@ -41,10 +44,15 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
             return true;
         }
 
-        // 请求头存在跳过认证标识,直接放行
+        // 请求头存在跳过认证标识:校验签名 token(compat 模式兼容明文)通过后放行
         String skipAuth = request.getHeader(SKIP_AUTH_HEADER);
         if (StringUtils.isNotEmpty(skipAuth)) {
-            return true;
+            Long skipUserId = skipAuthTokenService.resolveUserId(skipAuth);
+            if (skipUserId != null) {
+                request.setAttribute(USER_KEY, skipUserId);
+                return true;
+            }
+            // skipAuth 非法,不短路放行,继续走 APPToken 校验
         }
 
         //获取用户凭证

+ 9 - 6
fs-user-app/src/main/java/com/fs/framework/aspectj/UserOperationLogAspect.java

@@ -15,6 +15,7 @@ import com.fs.course.mapper.FsUserCourseVideoMapper;
 import com.fs.course.param.FsCourseQuestionAnswerUParam;
 import com.fs.course.param.FsCourseSendRewardUParam;
 import com.fs.course.param.newfs.FsUserCourseVideoLinkParam;
+import com.fs.course.utils.CourseSkipAuthTokenService;
 import com.fs.course.vo.FsUserCourseVO;
 import com.fs.course.vo.newfs.FsUserCourseVideoPageListVO;
 import com.fs.his.domain.FsUser;
@@ -47,6 +48,8 @@ public class UserOperationLogAspect {
     private FsUserCourseVideoMapper userCourseVideoMapper;
     @Autowired
     JwtUtils jwtUtils;
+    @Autowired
+    CourseSkipAuthTokenService skipAuthTokenService;
 
     private final ObjectMapper objectMapper = new ObjectMapper();
     private static final ThreadLocal<FsUserOperationLog> LOG_HOLDER = new ThreadLocal<>();
@@ -109,15 +112,15 @@ public class UserOperationLogAspect {
 
             //用户
             Long userId = null;
-            // 优先从 X-Skip-Auth 请求头取 userId(跳过token校验场景)
+            // 优先从 X-Skip-Auth 请求头取 userId(跳过token校验场景),校验签名 token
             String skipAuth = ServletUtils.getRequest().getHeader(SKIP_AUTH_HEADER);
             if (StringUtils.isNotEmpty(skipAuth)) {
-                try {
-                    userId = Long.valueOf(skipAuth);
-                } catch (NumberFormatException ne) {
-                    log.info("X-Skip-Auth 请求头不是有效的userId: {}", skipAuth);
+                userId = skipAuthTokenService.resolveUserId(skipAuth);
+                if (userId == null) {
+                    log.info("X-Skip-Auth 请求头校验失败: {}", skipAuth);
                 }
-            } else {
+            }
+            if (userId == null) {
                 try {
                     userId = Long.valueOf(jwtUtils.getClaimByToken(ServletUtils.getRequest().getHeader("APPToken")).getSubject().toString());
                 } catch (Exception ie) {