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.SaHttpMethod;
|
||||||
import cn.dev33.satoken.router.SaRouter;
|
import cn.dev33.satoken.router.SaRouter;
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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.InterceptorRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class SaTokenConfigure implements WebMvcConfigurer {
|
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
|
@Override
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
@@ -19,6 +39,10 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
|||||||
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
|
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
|
||||||
.addPathPatterns("/**")
|
.addPathPatterns("/**")
|
||||||
.excludePathPatterns(getExcludePaths());
|
.excludePathPatterns(getExcludePaths());
|
||||||
|
appSecretMap.put(appId, appSecret);
|
||||||
|
registry.addInterceptor(new SignInterceptor(appSecretMap,redisTemplate))
|
||||||
|
.addPathPatterns("/**") // 需要签名校验的接口
|
||||||
|
.excludePathPatterns(getExcludePaths()); // 不需要校验的接口;
|
||||||
}
|
}
|
||||||
private String[] getExcludePaths() {
|
private String[] getExcludePaths() {
|
||||||
return new String[]{
|
return new String[]{
|
||||||
@@ -41,7 +65,8 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
|||||||
"/demo/testSaveEmbed",
|
"/demo/testSaveEmbed",
|
||||||
"/demo/testSearch",
|
"/demo/testSearch",
|
||||||
"/demo/tsetSearchText",
|
"/demo/tsetSearchText",
|
||||||
"/file/upload"
|
"/file/upload",
|
||||||
|
"/user/logout"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.yolo.keyborad.controller;
|
package com.yolo.keyborad.controller;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import com.yolo.keyborad.common.BaseResponse;
|
import com.yolo.keyborad.common.BaseResponse;
|
||||||
import com.yolo.keyborad.common.ResultUtils;
|
import com.yolo.keyborad.common.ResultUtils;
|
||||||
import com.yolo.keyborad.model.dto.AppleLoginReq;
|
import com.yolo.keyborad.model.dto.AppleLoginReq;
|
||||||
@@ -40,5 +41,11 @@ public class UserController {
|
|||||||
return ResultUtils.success(appleService.login(appleLoginReq.getIdentityToken()));
|
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 解码
|
// 2. 拆分三段 & 使用 Base64URL 解码
|
||||||
String[] parts = identityToken.split("\\.");
|
String[] parts = identityToken.split("\\.");
|
||||||
if (parts.length != 3) {
|
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();
|
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:
|
db-config:
|
||||||
logic-delete-field: isDelete # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
|
logic-delete-field: isDelete # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
|
||||||
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
|
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