feat(auth): 新增接口签名校验与退出登录功能

This commit is contained in:
2025-12-03 12:59:51 +08:00
parent fdc024e58f
commit 6c7bec8ad3
6 changed files with 242 additions and 3 deletions

View File

@@ -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<String, String> 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<String, String> 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<String, String> 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<String, Object> 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<String, Object> result = new HashMap<>();
result.put("code", code);
result.put("message", msg);
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}

View File

@@ -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<String, String> 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

View File

@@ -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<Boolean> logout() {
StpUtil.logoutByTokenValue(StpUtil.getTokenValue());
return ResultUtils.success(true);
}
}

View File

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

View File

@@ -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<String, String> params, String secret) {
// 1. 过滤掉空值和 sign 本身
Map<String, String> 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<String, String> 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;
}
}
}

View File

@@ -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)
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
appid: loveKeyboard
appsecret: kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H