diff --git a/src/main/java/com/yolo/keyborad/Interceptor/SignInterceptor.java b/src/main/java/com/yolo/keyborad/Interceptor/SignInterceptor.java new file mode 100644 index 0000000..e5fe1cc --- /dev/null +++ b/src/main/java/com/yolo/keyborad/Interceptor/SignInterceptor.java @@ -0,0 +1,129 @@ +package com.yolo.keyborad.Interceptor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yolo.keyborad.utils.SignUtils; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.util.StreamUtils; +import org.springframework.web.servlet.HandlerInterceptor; + + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.TimeUnit; + +public class SignInterceptor implements HandlerInterceptor { + + // appId -> secret 的映射(可从 DB 等处加载) + private final Map appSecretMap; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final StringRedisTemplate redisTemplate; + + // 允许时间误差 5 分钟 + private static final long ALLOW_TIME_DIFF_SECONDS = 300; + // nonce 在 Redis 的有效期(建议比时间误差略长一点) + private static final long NONCE_EXPIRE_SECONDS = 300; + + public SignInterceptor(Map appSecretMap, + StringRedisTemplate redisTemplate) { + this.appSecretMap = appSecretMap; + this.redisTemplate = redisTemplate; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + + String appId = request.getHeader("X-App-Id"); + String timestamp = request.getHeader("X-Timestamp"); + String nonce = request.getHeader("X-Nonce"); + String sign = request.getHeader("X-Sign"); + + if (appId == null || timestamp == null || nonce == null || sign == null) { + writeError(response, 401, "Missing sign headers"); + return false; + } + + String secret = appSecretMap.get(appId); + if (secret == null) { + writeError(response, 401, "Invalid appId"); + return false; + } + + // 1. 时间戳校验(防止超时重放) + long ts; + try { + ts = Long.parseLong(timestamp); + } catch (NumberFormatException e) { + writeError(response, 401, "Invalid timestamp"); + return false; + } + long now = Instant.now().getEpochSecond(); + if (Math.abs(now - ts) > ALLOW_TIME_DIFF_SECONDS) { + writeError(response, 401, "Request expired"); + return false; + } + + // 2. nonce 防重放(Redis + setIfAbsent) + String nonceKey = buildNonceKey(appId, nonce); + Boolean success = redisTemplate.opsForValue() + .setIfAbsent(nonceKey, timestamp, NONCE_EXPIRE_SECONDS, TimeUnit.SECONDS); + // success == null 也当 false 处理 + if (!Boolean.TRUE.equals(success)) { + // 说明这个 appId + nonce 在有效期内已经被用过了 + writeError(response, 401, "Nonce already used"); + return false; + } + + // 3. 收集所有参数并校验签名(跟之前一样) + Map params = new HashMap<>(); + params.put("appId", appId); + params.put("timestamp", timestamp); + params.put("nonce", nonce); + + // query 参数 + request.getParameterMap().forEach((k, v) -> { + if (v != null && v.length > 0) { + params.put(k, v[0]); + } + }); + + // body 参数(content-type 为 json 时解析一次) + String contentType = request.getContentType(); + if (contentType != null && contentType.contains(MediaType.APPLICATION_JSON_VALUE)) { + String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); + if (!body.isEmpty()) { + Map bodyMap = objectMapper.readValue(body, Map.class); + bodyMap.forEach((k, v) -> { + if (v != null) { + params.put(k, String.valueOf(v)); + } + }); + } + } + + String calculatedSign = SignUtils.sign(params, secret); + if (!calculatedSign.equalsIgnoreCase(sign)) { + writeError(response, 401, "Invalid sign"); + return false; + } + + return true; + } + + private String buildNonceKey(String appId, String nonce) { + // 可以按需加上前缀,便于区分业务 + return "sign:nonce:" + appId + ":" + nonce; + } + + private void writeError(HttpServletResponse response, int code, String msg) throws Exception { + response.setStatus(code); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + Map result = new HashMap<>(); + result.put("code", code); + result.put("message", msg); + response.getWriter().write(objectMapper.writeValueAsString(result)); + } +} diff --git a/src/main/java/com/yolo/keyborad/config/SaTokenConfigure.java b/src/main/java/com/yolo/keyborad/config/SaTokenConfigure.java index 14250d9..eb08929 100644 --- a/src/main/java/com/yolo/keyborad/config/SaTokenConfigure.java +++ b/src/main/java/com/yolo/keyborad/config/SaTokenConfigure.java @@ -5,13 +5,33 @@ import cn.dev33.satoken.interceptor.SaInterceptor; import cn.dev33.satoken.router.SaHttpMethod; import cn.dev33.satoken.router.SaRouter; import cn.dev33.satoken.stp.StpUtil; +import com.yolo.keyborad.Interceptor.SignInterceptor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.HashMap; + @Configuration public class SaTokenConfigure implements WebMvcConfigurer { + + private final StringRedisTemplate redisTemplate; + + public SaTokenConfigure(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + HashMap appSecretMap = new HashMap<>(); + + @Value("${appid}") + private String appId; + @Value("${appsecret}") + private String appSecret; + + // 注册拦截器 @Override public void addInterceptors(InterceptorRegistry registry) { @@ -19,6 +39,10 @@ public class SaTokenConfigure implements WebMvcConfigurer { registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin())) .addPathPatterns("/**") .excludePathPatterns(getExcludePaths()); + appSecretMap.put(appId, appSecret); + registry.addInterceptor(new SignInterceptor(appSecretMap,redisTemplate)) + .addPathPatterns("/**") // 需要签名校验的接口 + .excludePathPatterns(getExcludePaths()); // 不需要校验的接口; } private String[] getExcludePaths() { return new String[]{ @@ -41,7 +65,8 @@ public class SaTokenConfigure implements WebMvcConfigurer { "/demo/testSaveEmbed", "/demo/testSearch", "/demo/tsetSearchText", - "/file/upload" + "/file/upload", + "/user/logout" }; } @Bean diff --git a/src/main/java/com/yolo/keyborad/controller/UserController.java b/src/main/java/com/yolo/keyborad/controller/UserController.java index b93d085..e0f12e6 100644 --- a/src/main/java/com/yolo/keyborad/controller/UserController.java +++ b/src/main/java/com/yolo/keyborad/controller/UserController.java @@ -1,5 +1,6 @@ package com.yolo.keyborad.controller; +import cn.dev33.satoken.stp.StpUtil; import com.yolo.keyborad.common.BaseResponse; import com.yolo.keyborad.common.ResultUtils; import com.yolo.keyborad.model.dto.AppleLoginReq; @@ -40,5 +41,11 @@ public class UserController { return ResultUtils.success(appleService.login(appleLoginReq.getIdentityToken())); } + @GetMapping("/logout") + @Operation(summary = "退出登录", description = "退出登录接口") + public BaseResponse logout() { + StpUtil.logoutByTokenValue(StpUtil.getTokenValue()); + return ResultUtils.success(true); + } } diff --git a/src/main/java/com/yolo/keyborad/service/impl/AppleServiceImpl.java b/src/main/java/com/yolo/keyborad/service/impl/AppleServiceImpl.java index 182314c..30d7919 100644 --- a/src/main/java/com/yolo/keyborad/service/impl/AppleServiceImpl.java +++ b/src/main/java/com/yolo/keyborad/service/impl/AppleServiceImpl.java @@ -57,7 +57,8 @@ public class AppleServiceImpl implements IAppleService { // 2. 拆分三段 & 使用 Base64URL 解码 String[] parts = identityToken.split("\\."); if (parts.length != 3) { - throw new BusinessException(ErrorCode.OPERATION_ERROR); + log.error("apple授权登录的token拆分失败,token={}", identityToken); + throw new BusinessException(ErrorCode.APPLE_LOGIN_ERROR); } Base64.Decoder urlDecoder = Base64.getUrlDecoder(); diff --git a/src/main/java/com/yolo/keyborad/utils/SignUtils.java b/src/main/java/com/yolo/keyborad/utils/SignUtils.java new file mode 100644 index 0000000..1f0e9bf --- /dev/null +++ b/src/main/java/com/yolo/keyborad/utils/SignUtils.java @@ -0,0 +1,74 @@ + package com.yolo.keyborad.utils; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.net.URLEncoder; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.TreeMap; + +public class SignUtils { + + private static final String HMAC_SHA256 = "HmacSHA256"; + + /** + * 生成签名 + * @param params 参与签名的参数(不含 sign 本身) + * @param secret 密钥 + */ + public static String sign(Map params, String secret) { + // 1. 过滤掉空值和 sign 本身 + Map filtered = params.entrySet().stream() + .filter(e -> e.getValue() != null && !"sign".equalsIgnoreCase(e.getKey())) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue + )); + + // 2. 按 key 字典序排序 + Map sorted = new TreeMap<>(filtered); + + // 3. 拼接成 key=value&key2=value2...&secret=xxx + StringBuilder sb = new StringBuilder(); + sorted.forEach((k, v) -> { + if (sb.length() > 0) { + sb.append("&"); + } + sb.append(k).append("=").append(urlEncode(v)); + }); + sb.append("&secret=").append(urlEncode(secret)); + + String data = sb.toString(); + return hmacSha256(data, secret); + } + + private static String hmacSha256(String data, String secret) { + try { + Mac mac = Mac.getInstance(HMAC_SHA256); + SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256); + mac.init(secretKey); + byte[] bytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + // 转十六进制小写 + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + String hex = Integer.toHexString(b & 0xff); + if (hex.length() == 1) { + sb.append("0"); + } + sb.append(hex); + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException("Sign error", e); + } + } + + private static String urlEncode(String str) { + try { + return URLEncoder.encode(str, StandardCharsets.UTF_8); + } catch (Exception e) { + return str; + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b6e5d8c..01c7aff 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -45,4 +45,7 @@ mybatis-plus: db-config: logic-delete-field: isDelete # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2) logic-delete-value: 1 # 逻辑已删除值(默认为 1) - logic-not-delete-value: 0 # 逻辑未删除值(默认为 0) \ No newline at end of file + logic-not-delete-value: 0 # 逻辑未删除值(默认为 0) + +appid: loveKeyboard +appsecret: kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H \ No newline at end of file