|
|
@@ -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();
|
|
|
+ }
|
|
|
+}
|