Просмотр исходного кода

Merge branch 'master' into test

wangyunpeng 12 часов назад
Родитель
Сommit
dcbf34e268

+ 2 - 2
api-module/src/main/java/com/tzld/piaoquan/api/annotation/RequireSign.java

@@ -6,8 +6,8 @@ import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
 /**
- * 标记接口需要请求签名校验(防重放)
- * 前端需携带 X-Timestamp / X-Nonce / X-Sign 头
+ * 标记接口需要请求防重放校验。
+ * 客户端需携带 X-Timestamp / X-Nonce 请求头。
  */
 @Target(ElementType.METHOD)
 @Retention(RetentionPolicy.RUNTIME)

+ 39 - 41
api-module/src/main/java/com/tzld/piaoquan/api/config/JwtInterceptor.java

@@ -17,21 +17,16 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.http.HttpMethod;
 import org.springframework.stereotype.Component;
-import org.springframework.util.StreamUtils;
 import org.springframework.web.method.HandlerMethod;
 import org.springframework.web.servlet.HandlerInterceptor;
 
-import javax.crypto.Mac;
-import javax.crypto.spec.SecretKeySpec;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import java.nio.charset.StandardCharsets;
-import java.security.MessageDigest;
-import java.util.Base64;
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 
@@ -46,6 +41,9 @@ public class JwtInterceptor implements HandlerInterceptor {
 
     private static final String SLASH = "/";
     public static String TOKEN_PREFIX = "login.{token}";
+    private static final String TOKEN_TEL_PREFIX = "login.token.tel.";
+    private static final String TOKEN_LIST_PREFIX = "login.account.token.list.";
+    private static final long TOKEN_INDEX_EXPIRE_TIME = RedisUtils.DEFAULT_EXPIRE_TIME + 24 * 60 * 60L;
     private Set<String> excludePaths;
     private Set<String> excludeUris;
 
@@ -100,7 +98,7 @@ public class JwtInterceptor implements HandlerInterceptor {
 
         validToken(authHeader);
 
-        // 请求签名校验(防重放),标记了 @RequireSign 的接口需要验
+        // 标记了 @RequireSign 的接口需要进行防重放校
         if (handler instanceof HandlerMethod) {
             HandlerMethod handlerMethod = (HandlerMethod) handler;
             RequireSign requireSign = handlerMethod.getMethodAnnotation(RequireSign.class);
@@ -125,8 +123,10 @@ public class JwtInterceptor implements HandlerInterceptor {
      * 校验Token
      */
     private void validToken(String authHeader) {
-        String loginUserString = redisUtils.getString(TOKEN_PREFIX.replace("{token}", authHeader));
+        String key = TOKEN_PREFIX.replace("{token}", authHeader);
+        String loginUserString = redisUtils.getString(key);
         if (StringUtils.isBlank(loginUserString)) {
+            cleanExpiredTokenAsync(authHeader);
             throw new CommonException(ExceptionEnum.Not_LOGIN);
         }
         ContentPlatformAccount account;
@@ -135,6 +135,9 @@ public class JwtInterceptor implements HandlerInterceptor {
         } catch (Exception e) {
             throw new CommonException(ExceptionEnum.Not_LOGIN);
         }
+        if (Objects.isNull(account)) {
+            throw new CommonException(ExceptionEnum.Not_LOGIN);
+        }
         // token验证完成后获取对应信息,存放在本地线程中
         LoginUserContext.setLoginUser(account);
         // 异步处理续期
@@ -182,9 +185,7 @@ public class JwtInterceptor implements HandlerInterceptor {
     }
 
     /**
-     * 请求签名校验:HMAC-SHA256(method:uri:timestamp:nonce:body, signKey)
-     * - timestamp 必须在 ±5 分钟内
-     * - nonce 全局唯一(Redis setIfAbsent,10 分钟过期)
+     * 请求防重放校验:timestamp 必须在 ±5 分钟内,同一 token 的 nonce 只能使用一次。
      */
     private void validateSign(HttpServletRequest request, String token) {
         String timestamp = request.getHeader("X-Timestamp");
@@ -202,7 +203,8 @@ public class JwtInterceptor implements HandlerInterceptor {
             throw new CommonException(ExceptionEnum.PARAM_ERROR.getCode(), "时间戳格式错误");
         }
         long now = System.currentTimeMillis();
-        if (Math.abs(now - requestTime) > 5 * 60 * 1000) {
+        long allowedSkew = 5 * 60 * 1000L;
+        if (requestTime < now - allowedSkew || requestTime > now + allowedSkew) {
             throw new CommonException(ExceptionEnum.PARAM_ERROR.getCode(), "请求已过期");
         }
 
@@ -216,30 +218,8 @@ public class JwtInterceptor implements HandlerInterceptor {
 
     }
 
-    private String getRequestBody(HttpServletRequest request) {
-        try {
-            byte[] body = StreamUtils.copyToByteArray(request.getInputStream());
-            return new String(body, StandardCharsets.UTF_8);
-        } catch (Exception e) {
-            log.warn("Failed to read request body for sign validation", e);
-            return "";
-        }
-    }
-
-    private String hmacSha256(String data, String key) {
-        try {
-            Mac mac = Mac.getInstance("HmacSHA256");
-            SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
-            mac.init(keySpec);
-            byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
-            return Base64.getEncoder().encodeToString(hash);
-        } catch (Exception e) {
-            throw new RuntimeException("HMAC-SHA256 computation failed", e);
-        }
-    }
-
     private void renewalToken(String token) {
-        executor.execute(() -> {
+        executeAsync(() -> {
             try {
                 String key = TOKEN_PREFIX.replace("{token}", token);
                 String loginUserString = redisUtils.getString(key);
@@ -255,7 +235,12 @@ public class JwtInterceptor implements HandlerInterceptor {
                     if (Objects.nonNull(account) && Objects.nonNull(account.getId())) {
                         ContentPlatformAccount latestAccount = accountService.getAccountById(account.getId());
                         if (Objects.nonNull(latestAccount)) {
-                            redisUtils.setValueWithExpire(key, JSON.toJSONString(latestAccount), RedisUtils.DEFAULT_EXPIRE_TIME);
+                            boolean renewed = redisUtils.setValueWithExpireIfPresent(key,
+                                    JSON.toJSONString(latestAccount), RedisUtils.DEFAULT_EXPIRE_TIME);
+                            if (renewed && StringUtils.isNotBlank(latestAccount.getTelNum())) {
+                                redisUtils.setValueWithExpire(TOKEN_TEL_PREFIX + token,
+                                        latestAccount.getTelNum(), TOKEN_INDEX_EXPIRE_TIME);
+                            }
                         }
                     }
                 }
@@ -263,7 +248,20 @@ public class JwtInterceptor implements HandlerInterceptor {
                 // 续期失败不影响正常请求,仅记录日志
                 log.error("renewalToken error, token={}", token, e);
             }
-        });
+        }, "renewalToken", token);
+    }
+
+    private void cleanExpiredTokenAsync(String token) {
+        executeAsync(() -> cleanExpiredToken(token), "cleanExpiredToken", token);
+    }
+
+    private void executeAsync(Runnable task, String taskName, String token) {
+        try {
+            executor.execute(task);
+        } catch (RejectedExecutionException e) {
+            // 异步维护失败不能改变本次请求的鉴权结果
+            log.error("{} rejected, token={}", taskName, token, e);
+        }
     }
 
     /**
@@ -271,13 +269,13 @@ public class JwtInterceptor implements HandlerInterceptor {
      */
     private void cleanExpiredToken(String token) {
         try {
-            String telKey = "login.token.tel." + token;
+            String telKey = TOKEN_TEL_PREFIX + token;
             String telNum = redisUtils.getString(telKey);
             if (StringUtils.isBlank(telNum)) {
                 redisUtils.del(telKey);
                 return;
             }
-            String listKey = "login.account.token.list." + telNum;
+            String listKey = TOKEN_LIST_PREFIX + telNum;
             List<String> tokens = redisUtils.listRange(listKey);
             if (tokens == null || tokens.isEmpty()) {
                 redisUtils.del(telKey);
@@ -291,7 +289,7 @@ public class JwtInterceptor implements HandlerInterceptor {
                 if (StringUtils.isBlank(redisUtils.getString(loginKey))) {
                     // token 已过期,从列表和映射中移除
                     redisUtils.listRemove(listKey, t);
-                    redisUtils.del("login.token.tel." + t);
+                    redisUtils.del(TOKEN_TEL_PREFIX + t);
                     redisUtils.del("sign_key." + t);
                 }
             }
@@ -305,4 +303,4 @@ public class JwtInterceptor implements HandlerInterceptor {
             ex) throws Exception {
         LoginUserContext.remove();
     }
-}
+}

+ 9 - 4
api-module/src/main/java/com/tzld/piaoquan/api/service/contentplatform/impl/ContentPlatformAccountServiceImpl.java

@@ -32,6 +32,9 @@ import java.util.*;
 @Service
 public class ContentPlatformAccountServiceImpl implements ContentPlatformAccountService {
 
+    private static final long TOKEN_EXPIRE_TIME = 7L * 24 * 60 * 60;
+    private static final long TOKEN_INDEX_EXPIRE_TIME = TOKEN_EXPIRE_TIME + 24 * 60 * 60L;
+
     @Autowired
     ContentPlatformAccountMapper accountMapper;
     @Autowired
@@ -87,7 +90,7 @@ public class ContentPlatformAccountServiceImpl implements ContentPlatformAccount
         saveTokenToRedis(result, account.getToken(), token);
         // 保存 signKey 到 Redis,与 token 同生命周期(7天)
         String signKeyRedisKey = "sign_key." + token;
-        redisUtils.setValueWithExpire(signKeyRedisKey, signKey, 7L * 24 * 60 * 60);
+        redisUtils.setValueWithExpire(signKeyRedisKey, signKey, TOKEN_EXPIRE_TIME);
         account.setToken(token);
         account.setTokenExpireTimestamp(now + 7 * 24 * 60 * 60 * 1000L);
         accountMapper.updateByPrimaryKeySelective(account);
@@ -109,12 +112,14 @@ public class ContentPlatformAccountServiceImpl implements ContentPlatformAccount
         String tokenPrefix = "login.{token}";
         // 允许同一账号多端登录
         String redisKey = tokenPrefix.replace("{token}", token);
-        redisUtils.setValueWithExpire(redisKey, JSON.toJSONString(loginInfo), 7 * 24 * 60 * 60L);
+        redisUtils.setValueWithExpire(redisKey, JSON.toJSONString(loginInfo), TOKEN_EXPIRE_TIME);
         String tokenListKey = "login.account.token.list." + loginInfo.getTelNum();
+        // 账号资料刷新时可能重新保存同一个 token,先移除以避免列表重复。
+        redisUtils.listRemove(tokenListKey, token);
         redisUtils.listLeftPush(tokenListKey, token);
-        // 存 token → telNum 映射,用于 token 过期时从 list 中清理
+        // 索引比 token 多保留一天,保证过期请求仍能定位并清理账号 token 列表。
         String tokenTelKey = "login.token.tel." + token;
-        redisUtils.setValueWithExpire(tokenTelKey, loginInfo.getTelNum(), 7 * 24 * 60 * 60L);
+        redisUtils.setValueWithExpire(tokenTelKey, loginInfo.getTelNum(), TOKEN_INDEX_EXPIRE_TIME);
     }
 
     @Override

+ 11 - 0
common-module/src/main/java/com/tzld/piaoquan/growth/common/utils/RedisUtils.java

@@ -61,6 +61,17 @@ public class RedisUtils {
         redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS);
     }
 
+    /**
+     * 仅当 key 仍存在时更新值和过期时间,避免异步续期复活已过期或已删除的 key。
+     */
+    public boolean setValueWithExpireIfPresent(String key, String value, Long expireTime) {
+        String lua = "if redis.call('EXISTS', KEYS[1]) == 1 then "
+                + "redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2]) return 1 else return 0 end";
+        DefaultRedisScript<Long> script = new DefaultRedisScript<>(lua, Long.class);
+        Long result = redisTemplate.execute(script, Collections.singletonList(key), value, String.valueOf(expireTime));
+        return Long.valueOf(1L).equals(result);
+    }
+
     public void setIncrementValue(String key, long value, Date date) {
         Long expireTime;
         if (date != null) {