签名校验
签名校验通过 HMAC-SHA256 签名 + 时间戳 + 随机数 三重机制,防止 API 接口数据被篡改和重放攻击。
功能说明
签名校验基于 Filter 全局拦截实现,自动校验请求参数的签名、时间戳和随机数,无需修改业务代码。
核心特性
✅ 防篡改
- 参数签名:所有参数按字典序排列后做 HMAC-SHA256
- 密钥保护:签名密钥不在请求中明文传输(仅作为 HMAC 密钥与规范化串尾部)
- HMAC 校验:任何参数被修改都会导致签名验证失败
✅ 防重放
- 时间戳校验:请求超过有效期自动拒绝
- 随机数验证:nonce 值只能使用一次
- 双重保护:时间戳 + nonce 彻底防止重放攻击
✅ 灵活配置
- 白名单支持:公开接口无需签名
- 自定义参数名:可配置 sign、timestamp、nonce 参数名
- 可调整有效期:根据业务需求配置过期时间
配置
基础配置
yaml
# application.yml
molandev:
encrypt:
sign:
enabled: true # 启用签名校验,必填
secret: your-sign-secret # 签名密钥,必填
sign-name: sign # 签名参数名,默认 sign
timestamp-name: timestamp # 时间戳参数名,默认 timestamp
nonce-name: nonce # 随机数参数名,默认 nonce
expire-time: 300 # 有效期(秒),默认5分钟
url-pattern: /* # 拦截路径,默认 /*
order: -2147483628 # Filter 优先级,默认 Integer.MIN_VALUE + 20
whitelist: # 白名单,默认空
- /api/public/**
- /health
- /actuator/**注意
order 值越小优先级越高。默认值 Integer.MIN_VALUE + 20 保证在混合加密解密之后执行。
配置项说明
| 配置项 | 类型 | 默认值 | 必填 | 说明 |
|---|---|---|---|---|
| enabled | boolean | false | ✅ | 是否启用签名校验 |
| secret | String | - | ✅ | 签名密钥 |
| sign-name | String | sign | ❌ | 签名参数名 |
| timestamp-name | String | timestamp | ❌ | 时间戳参数名 |
| nonce-name | String | nonce | ❌ | 随机数参数名 |
| expire-time | long | 300 | ❌ | 有效期(秒) |
| url-pattern | String | /* | ❌ | 拦截路径模式 |
| order | int | Integer.MIN_VALUE + 20 | ❌ | Filter 执行顺序,值越小优先级越高,在混合加密之后执行 |
| whitelist | List<String> | [] | ❌ | 白名单路径,支持 Ant 匹配 |
使用示例
示例 1:基础签名
前端生成签名
javascript
import HmacSHA256 from 'crypto-js/hmac-sha256';
import Hex from 'crypto-js/enc-hex';
/**
* 生成签名(与后端 SignUtils 对齐)
* 规范化串:过滤空值与 sign → key 字典序 → k=v&...&secret=xxx
* 签名:HMAC-SHA256(规范化串, secret) → hex → 大写
*/
function generateSign(params, secret) {
params.timestamp = Date.now();
params.nonce = Math.random().toString(36).substring(2, 15);
const filteredParams = {};
Object.keys(params).forEach(key => {
if (params[key] !== null && params[key] !== '' && key !== 'sign') {
filteredParams[key] = params[key];
}
});
const sortedKeys = Object.keys(filteredParams).sort();
const str = sortedKeys.map(key => `${key}=${filteredParams[key]}`).join('&');
const signStr = str + `&secret=${secret}`;
// HMAC-SHA256,密钥为 secret;结果转大写 hex
params.sign = HmacSHA256(signStr, secret).toString(Hex).toUpperCase();
return params;
}
// 使用示例
const params = {
orderId: '202401180001',
amount: 100.00,
userId: '10086'
};
const signedParams = generateSign(params, 'your-sign-secret');
console.log(signedParams);也可使用 Web Crypto(无需 crypto-js):
javascript
async function hmacSha256HexUpper(message, secret) {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const sig = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(message)
);
return Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.toUpperCase();
}后端自动校验
java
@RestController
@RequestMapping("/api/order")
public class OrderController {
// ✅ Filter 会自动校验签名,Controller 无需任何修改
@GetMapping("/query")
public Result<Order> query(
@RequestParam String orderId,
@RequestParam BigDecimal amount,
@RequestParam String userId
// timestamp、nonce、sign 由 Filter 自动处理
) {
return orderService.getOrder(orderId);
}
}示例 2:POST 请求签名
前端实现
javascript
import HmacSHA256 from 'crypto-js/hmac-sha256';
import Hex from 'crypto-js/enc-hex';
async function signedPost(url, data, secret) {
const signParams = {
timestamp: Date.now(),
nonce: Math.random().toString(36).substring(2, 15)
};
const urlObj = new URL(url, window.location.origin);
urlObj.searchParams.forEach((value, key) => {
signParams[key] = value;
});
const sortedKeys = Object.keys(signParams).sort();
const str = sortedKeys.map(key => `${key}=${signParams[key]}`).join('&');
const signStr = str + `&secret=${secret}`;
signParams.sign = HmacSHA256(signStr, secret).toString(Hex).toUpperCase();
Object.keys(signParams).forEach(key => {
urlObj.searchParams.append(key, signParams[key]);
});
const response = await fetch(urlObj.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
}
const result = await signedPost('/api/order/create', {
orderId: '202401180001',
amount: 100.00
}, 'your-sign-secret');后端接收
java
@RestController
@RequestMapping("/api/order")
public class OrderController {
@PostMapping("/create")
public Result<Long> create(@RequestBody OrderRequest request) {
// ✅ Filter 已校验 URL 参数中的签名
// request body 不参与签名,仅传输业务数据
Long orderId = orderService.create(request);
return Result.success(orderId);
}
}示例 3:封装签名工具类
javascript
// sign-utils.js
import HmacSHA256 from 'crypto-js/hmac-sha256';
import Hex from 'crypto-js/enc-hex';
export class SignUtils {
constructor(secret) {
this.secret = secret;
}
sign(params = {}) {
const signParams = {
...params,
timestamp: Date.now(),
nonce: this.generateNonce()
};
signParams.sign = this.generateSign(signParams);
return signParams;
}
generateSign(params) {
const filteredParams = {};
Object.keys(params)
.filter(key => params[key] !== null && params[key] !== '' && key !== 'sign')
.sort()
.forEach(key => {
filteredParams[key] = params[key];
});
const str = Object.keys(filteredParams)
.map(key => `${key}=${filteredParams[key]}`)
.join('&');
return HmacSHA256(str + `&secret=${this.secret}`, this.secret)
.toString(Hex)
.toUpperCase();
}
generateNonce() {
return Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
}
async get(url, params = {}) {
const signedParams = this.sign(params);
const queryString = new URLSearchParams(signedParams).toString();
const response = await fetch(`${url}?${queryString}`);
return response.json();
}
async post(url, data = {}, queryParams = {}) {
const signedParams = this.sign(queryParams);
const queryString = new URLSearchParams(signedParams).toString();
const response = await fetch(`${url}?${queryString}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
}
}
const signUtils = new SignUtils('your-sign-secret');
const order = await signUtils.get('/api/order/query', {
orderId: '202401180001'
});
const result = await signUtils.post('/api/order/create', {
orderId: '202401180001',
amount: 100.00
});示例 4:后端主动生成签名
java
import com.molandev.core.encrypt.sign.SignUtils;
@Service
public class ThirdPartyService {
@Value("${third-party.secret}")
private String secret;
public void callThirdParty(Map<String, String> params) {
params.put("timestamp", String.valueOf(System.currentTimeMillis()));
params.put("nonce", UUID.randomUUID().toString());
String sign = SignUtils.generateSign(params, secret);
params.put("sign", sign);
String url = buildUrl("/api/data/push", params);
RestTemplate restTemplate = new RestTemplate();
String result = restTemplate.getForObject(url, String.class);
}
}示例 5:Vue.js 集成
javascript
// plugins/sign.js
import { SignUtils } from '@/utils/sign-utils';
const signUtils = new SignUtils(import.meta.env.VITE_SIGN_SECRET);
export default {
install(app) {
app.config.globalProperties.$sign = signUtils;
app.config.globalProperties.$axios.interceptors.request.use(config => {
if (config.method === 'get') {
config.params = signUtils.sign(config.params || {});
} else {
const signedParams = signUtils.sign({});
const separator = config.url.includes('?') ? '&' : '?';
config.url += separator + new URLSearchParams(signedParams).toString();
}
return config;
});
}
};技术细节
签名算法
text
签名流程:
1. 参数准备
orderId=123&amount=100.00&userId=10086
2. 添加时间戳和随机数
orderId=123&amount=100.00&userId=10086×tamp=1705567200000&nonce=abc123
3. 过滤空值和 sign 字段
orderId=123&amount=100.00&userId=10086×tamp=1705567200000&nonce=abc123
4. 按 key 字典序排序
amount=100.00&nonce=abc123&orderId=123×tamp=1705567200000&userId=10086
5. 拼接密钥(写入规范化串尾部)
amount=100.00&nonce=abc123&orderId=123×tamp=1705567200000&userId=10086&secret=your-sign-secret
6. HMAC-SHA256(规范化串, secret) → hex → 大写
(示例值因 secret/参数而异,长度 64 位十六进制)对应实现:com.molandev.core.encrypt.sign.SignUtils。
Filter 实现
java
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
if (isWhitelisted(httpRequest.getRequestURI())) {
chain.doFilter(request, response);
return;
}
Map<String, String> params = new HashMap<>();
httpRequest.getParameterMap().forEach((key, values) -> {
if (values.length > 0) {
params.put(key, values[0]);
}
});
String sign = params.get(signConfig.getSignName());
String timestampStr = params.get(signConfig.getTimestampName());
String nonce = params.get(signConfig.getNonceName());
if (StringUtils.isEmpty(sign)) {
throw new SignException("签名不能为空");
}
if (StringUtils.isEmpty(timestampStr)) {
throw new SignException("时间戳不能为空");
}
if (StringUtils.isEmpty(nonce)) {
throw new SignException("随机数不能为空");
}
long timestamp = Long.parseLong(timestampStr);
if (!SignUtils.verifyTimestamp(timestamp, signConfig.getExpireTime())) {
throw new SignException("请求已过期");
}
if (nonceCache.containsKey(nonce)) {
throw new SignException("请求重复,nonce已使用");
}
if (!SignUtils.verifySign(params, sign, signConfig.getSecret())) {
throw new SignException("签名验证失败");
}
nonceCache.put(nonce, timestamp);
chain.doFilter(request, response);
}防重放机制
java
private final Map<String, Long> nonceCache = new ConcurrentHashMap<>();
nonceCache.put(nonce, timestamp);
private void cleanExpiredNonce(long expireTime) {
long currentTime = System.currentTimeMillis();
nonceCache.entrySet().removeIf(entry ->
currentTime - entry.getValue() > expireTime * 1000
);
}生产环境可将 nonceStore 配为 Redis(见 EncryptProperties.SignProperties)。
异常处理
SignFilter 校验失败时抛出 SignException,由 core 的 ExceptionWrapFilter 统一封装为:
json
{ "code": "2001", "msg": "签名验证失败", "data": null }无需应用侧再写包装 Filter。
注意事项
⚠️ 时间同步
客户端和服务器时间必须同步:
javascript
async function getServerTime() {
const response = await fetch('/api/server-time');
const { timestamp } = await response.json();
return timestamp;
}
const serverTime = await getServerTime();
params.timestamp = serverTime;⚠️ 密钥管理
- 不要硬编码:使用环境变量
- 定期更换:建立密钥轮换机制
- 前后端一致:确保使用相同的密钥
⚠️ nonce 存储
生产环境建议使用 Redis:
java
@Component
public class RedisNonceCache {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public boolean exists(String nonce) {
return Boolean.TRUE.equals(redisTemplate.hasKey("nonce:" + nonce));
}
public void save(String nonce, long expireTime) {
redisTemplate.opsForValue().set(
"nonce:" + nonce,
String.valueOf(System.currentTimeMillis()),
expireTime,
TimeUnit.SECONDS
);
}
}⚠️ 白名单配置
合理配置白名单,避免影响性能:
yaml
molandev:
encrypt:
sign:
whitelist:
- /api/public/**
- /health
- /actuator/**
- /doc.html
- /v3/api-docs/**常见问题
Q1: 签名验证失败怎么办?
A: 检查以下几点:
- 密钥是否一致:前后端使用相同的 secret
- 参数是否完整:确保所有参数都参与签名
- 排序是否正确:按字典序排序
- 算法是否正确:HMAC-SHA256(不是 MD5)
- 大小写:hex 结果转大写
调试工具:
java
System.out.println("生成的签名: " + generatedSign);
System.out.println("传入的签名: " + receivedSign);Q2: 如何调试签名问题?
A: 临时关闭签名校验:
yaml
# application-dev.yml
molandev:
encrypt:
sign:
enabled: false或使用白名单:
yaml
molandev:
encrypt:
sign:
whitelist:
- /**Q3: POST 请求的 body 需要参与签名吗?
A: 不需要。签名只针对 URL 参数:
- GET 请求:所有查询参数参与签名
- POST 请求:仅 URL 参数参与签名,body 不参与
Q4: 如何防止暴力破解签名?
A:
- 使用强密钥:至少 32 位随机字符
- 限流保护:接口添加限流
- IP 黑名单:多次失败封禁 IP
- 告警机制:签名失败告警
Q5: 时间戳精度是毫秒还是秒?
A: 毫秒。
javascript
timestamp: Date.now() // 毫秒java
System.currentTimeMillis() // 毫秒