Skip to content

签名校验

签名校验通过 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 保证在混合加密解密之后执行。

配置项说明

配置项类型默认值必填说明
enabledbooleanfalse是否启用签名校验
secretString-签名密钥
sign-nameStringsign签名参数名
timestamp-nameStringtimestamp时间戳参数名
nonce-nameStringnonce随机数参数名
expire-timelong300有效期(秒)
url-patternString/*拦截路径模式
orderintInteger.MIN_VALUE + 20Filter 执行顺序,值越小优先级越高,在混合加密之后执行
whitelistList<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&timestamp=1705567200000&nonce=abc123

3. 过滤空值和 sign 字段
   orderId=123&amount=100.00&userId=10086&timestamp=1705567200000&nonce=abc123

4. 按 key 字典序排序
   amount=100.00&nonce=abc123&orderId=123&timestamp=1705567200000&userId=10086

5. 拼接密钥(写入规范化串尾部)
   amount=100.00&nonce=abc123&orderId=123&timestamp=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: 检查以下几点:

  1. 密钥是否一致:前后端使用相同的 secret
  2. 参数是否完整:确保所有参数都参与签名
  3. 排序是否正确:按字典序排序
  4. 算法是否正确:HMAC-SHA256(不是 MD5)
  5. 大小写: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:

  1. 使用强密钥:至少 32 位随机字符
  2. 限流保护:接口添加限流
  3. IP 黑名单:多次失败封禁 IP
  4. 告警机制:签名失败告警

Q5: 时间戳精度是毫秒还是秒?

A: 毫秒。

javascript
timestamp: Date.now()  // 毫秒
java
System.currentTimeMillis()  // 毫秒

相关工具

参考资料