feat(auth): 新增接口签名校验与退出登录功能
This commit is contained in:
129
src/main/java/com/yolo/keyborad/Interceptor/SignInterceptor.java
Normal file
129
src/main/java/com/yolo/keyborad/Interceptor/SignInterceptor.java
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
74
src/main/java/com/yolo/keyborad/utils/SignUtils.java
Normal file
74
src/main/java/com/yolo/keyborad/utils/SignUtils.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user