时间戳 + 签名机制

对于无需登录的门户网站接口,既要避免固定 token 被滥用,又要实现动态鉴权,同时不影响用户体验(无需强制登录),可以采用以下几种鉴权方案,结合 “动态可变” 和 “轻量化” 特点设计:

一、时间戳 + 签名机制(推荐)

核心思路

客户端每次请求时,基于当前时间戳请求参数双方约定的密钥生成一个动态签名,服务端验证签名合法性和时间戳有效性(防止重放攻击)。
签名每次请求都不同(因时间戳变化),且无法伪造(依赖密钥),无需登录即可实现动态鉴权。

实现步骤
  1. 客户端生成请求参数

    • 固定参数:timestamp(当前时间戳,精确到秒,如 1620000000)、nonce(随机字符串,如 UUID,防止重复请求)。
    • 业务参数:接口所需的实际数据(如 page=1&size=10)。
    • 签名生成:将 timestamp + nonce + 业务参数(排序后)+ 密钥 拼接成字符串,通过 MD5 或 SHA256 加密生成 sign

    示例拼接规则(按参数名 ASCII 排序,避免顺序影响签名):
    key=密钥&nonce=xxx&page=1&size=10&timestamp=1620000000 → 加密后得到 sign

  2. 客户端请求
    将 timestampnoncesign 作为请求头(或参数)发送,例如:

    http

    GET /api/portal/data HTTP/1.1
    timestamp: 1620000000
    nonce: 5f4dcc3b5aa765d61d8327deb882cf99
    sign: a1b2c3d4e5f6...
    
  3. 服务端验证

    • 验证 timestamp 是否在有效时间窗口内(如 ±5 分钟,防止过期请求被重放)。
    • 验证 nonce 是否重复(可暂存最近 5 分钟的 nonce 到缓存,防止重复使用)。
    • 按客户端相同规则重新计算 sign,与请求中的 sign 比对,一致则通过。
优势
  • 签名动态变化(依赖时间戳和随机数),无法复用。
  • 无需登录,不影响用户体验。
  • 防重放(时间戳 + nonce)、防篡改(签名验证)。
注意
  • 密钥需在客户端(前端)加密存储(如通过混淆、环境变量注入),避免明文暴露。
  • 采用 HTTPS 传输,防止参数被中间人截取。

以下是基于 “时间戳 + 签名机制” 的完整实现,包括后端拦截器和前端 JS 请求示例,确保前后端签名逻辑一致。

一、后端拦截器实现(Spring Boot)

1. 签名工具类(核心,用于生成和验证签名)

java

import org.springframework.util.DigestUtils;
import java.nio.charset.StandardCharsets;
import java.util.*;

/**
 * 签名工具类:生成签名和验证签名
 */
public class SignUtil {

    // 密钥(实际项目中放在配置文件,前后端保持一致)
    public static final String SECRET_KEY = "your_portal_secret_key"; // 建议通过配置注入

    /**
     * 生成签名
     * @param method 请求方法(GET/POST等)
     * @param url 请求路径(如/api/data)
     * @param params 请求参数(Query参数或Body参数)
     * @param timestamp 时间戳(秒)
     * @param nonce 随机字符串
     * @return 签名串(SHA256加密)
     */
    public static String generateSign(String method, String url, Map<String, Object> params, long timestamp, String nonce) {
        // 1. 拼接参数:按参数名ASCII排序,避免顺序影响签名
        TreeMap<String, Object> sortedParams = new TreeMap<>(params);
        StringBuilder paramStr = new StringBuilder();
        sortedParams.forEach((k, v) -> paramStr.append(k).append("=").append(v).append("&"));
        if (paramStr.length() > 0) {
            paramStr.deleteCharAt(paramStr.length() - 1); // 移除最后一个&
        }

        // 2. 拼接签名源串:method + url + params + timestamp + nonce + secret
        String source = String.format("%s%s%s%d%s%s",
                method.toUpperCase(),
                url,
                paramStr,
                timestamp,
                nonce,
                SECRET_KEY);

        // 3. SHA256加密(也可使用MD5,但SHA256更安全)
        return DigestUtils.md5DigestAsHex(source.getBytes(StandardCharsets.UTF_8)); // 示例用MD5,实际建议SHA256
    }

    /**
     * 验证签名是否有效
     * @param method 请求方法
     * @param url 请求路径
     * @param params 请求参数
     * @param timestamp 时间戳(秒)
     * @param nonce 随机字符串
     * @param sign 待验证的签名
     * @return 是否有效
     */
    public static boolean verifySign(String method, String url, Map<String, Object> params, long timestamp, String nonce, String sign) {
        String generatedSign = generateSign(method, url, params, timestamp, nonce);
        return generatedSign.equals(sign);
    }
}

2. 拦截器实现(验证请求头)

java

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * 签名验证拦截器
 */
@Component
public class SignAuthInterceptor implements HandlerInterceptor {

    // 时间戳有效期(5分钟,单位:秒)
    private static final long TIMESTAMP_VALIDITY = 5 * 60;

    // Redis用于存储nonce,防止重复使用(需提前配置Redis)
    private final RedisTemplate<String, String> redisTemplate;

    public SignAuthInterceptor(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 获取请求头中的签名相关参数
        String timestampStr = request.getHeader("timestamp");
        String nonce = request.getHeader("nonce");
        String sign = request.getHeader("sign");

        // 2. 校验参数是否缺失
        if (Objects.isNull(timestampStr) || Objects.isNull(nonce) || Objects.isNull(sign)) {
            return writeError(response, "缺失签名参数(timestamp/nonce/sign)");
        }

        // 3. 校验时间戳有效性(防止重放攻击)
        long timestamp;
        try {
            timestamp = Long.parseLong(timestampStr);
        } catch (NumberFormatException e) {
            return writeError(response, "时间戳格式错误");
        }
        long currentTime = System.currentTimeMillis() / 1000; // 当前时间(秒)
        if (Math.abs(currentTime - timestamp) > TIMESTAMP_VALIDITY) {
            return writeError(response, "请求已过期(时间戳无效)");
        }

        // 4. 校验nonce是否重复(防止重放攻击)
        String nonceKey = "portal:nonce:" + nonce;
        if (Boolean.TRUE.equals(redisTemplate.hasKey(nonceKey))) {
            return writeError(response, "请求重复(nonce已使用)");
        }
        // 存储nonce,有效期同时间戳
        redisTemplate.opsForValue().set(nonceKey, "1", TIMESTAMP_VALIDITY, TimeUnit.SECONDS);

        // 5. 提取请求参数(包括Query和Body参数,GET取Query,POST取Body)
        Map<String, Object> params = new HashMap<>();
        // GET参数
        Enumeration<String> paramNames = request.getParameterNames();
        while (paramNames.hasMoreElements()) {
            String key = paramNames.nextElement();
            params.put(key, request.getParameter(key));
        }
        // POST参数(这里简化处理,实际需根据Content-Type解析Body,如JSON)
        // 如需处理POST Body,可通过request.getInputStream()读取并解析为Map

        // 6. 验证签名
        String method = request.getMethod();
        String url = request.getRequestURI();
        boolean signValid = SignUtil.verifySign(method, url, params, timestamp, nonce, sign);
        if (!signValid) {
            return writeError(response, "签名验证失败");
        }

        // 7. 所有验证通过,放行
        return true;
    }

    // 输出错误响应
    private boolean writeError(HttpServletResponse response, String message) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":401,\"message\":\"" + message + "\"}");
        return false;
    }
}
3. 注册拦截器(配置类)

java

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Resource
    private SignAuthInterceptor signAuthInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 拦截所有门户接口(根据实际路径调整)
        registry.addInterceptor(signAuthInterceptor)
                .addPathPatterns("/api/portal/**") // 拦截门户接口
                .excludePathPatterns("/api/portal/public/**"); // 排除完全公开的接口(如首页静态资源)
    }
}

二、前端 JS 请求示例(原生 JS + fetch)

javascript

/**
 * 门户接口请求工具(含签名生成)
 */
const PortalApi = {
    // 密钥(与后端一致,前端需通过安全方式存储,避免明文暴露)
    SECRET_KEY: 'your_portal_secret_key',

    /**
     * 生成随机字符串(nonce)
     * @returns {string} UUID格式字符串
     */
    generateNonce() {
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
            const r = Math.random() * 16 | 0;
            const v = c === 'x' ? r : (r & 0x3 | 0x8);
            return v.toString(16);
        });
    },

    /**
     * 生成签名
     * @param {string} method 请求方法(GET/POST)
     * @param {string} url 请求路径(如/api/portal/data)
     * @param {Object} params 请求参数
     * @param {number} timestamp 时间戳(秒)
     * @param {string} nonce 随机字符串
     * @returns {string} 签名串
     */
    generateSign(method, url, params, timestamp, nonce) {
        // 1. 排序参数(按ASCII码)
        const sortedParams = {};
        Object.keys(params).sort().forEach(key => {
            sortedParams[key] = params[key];
        });

        // 2. 拼接参数串
        let paramStr = '';
        Object.entries(sortedParams).forEach(([key, value], index) => {
            paramStr += `${key}=${value}`;
            if (index !== Object.keys(sortedParams).length - 1) {
                paramStr += '&';
            }
        });

        // 3. 拼接签名源串(与后端一致)
        const source = `${method.toUpperCase()}${url}${paramStr}${timestamp}${nonce}${this.SECRET_KEY}`;

        // 4. SHA256加密(需引入加密库,或使用MD5简化)
        // 实际项目推荐用CryptoJS:https://github.com/brix/crypto-js
        return this.md5(source); // 示例用MD5,实际建议SHA256
    },

    /**
     * 简易MD5加密(实际项目用成熟库)
     * @param {string} str 源字符串
     * @returns {string} MD5哈希值
     */
    md5(str) {
        // 简化实现,实际建议使用CryptoJS.MD5(str).toString()
        const crypto = require('crypto'); // Node环境,浏览器环境需用CryptoJS
        return crypto.createHash('md5').update(str).digest('hex');
    },

    /**
     * 发送请求
     * @param {string} method 请求方法
     * @param {string} url 请求地址
     * @param {Object} params 请求参数
     * @returns {Promise} 响应结果
     */
    async request(method, url, params = {}) {
        try {
            // 1. 生成签名参数
            const timestamp = Math.floor(Date.now() / 1000); // 时间戳(秒)
            const nonce = this.generateNonce();
            const sign = this.generateSign(method, url, params, timestamp, nonce);

            // 2. 处理请求参数(GET拼接在URL,POST放在Body)
            let requestUrl = url;
            let requestBody = null;
            if (method.toUpperCase() === 'GET') {
                const searchParams = new URLSearchParams(params);
                requestUrl += `?${searchParams.toString()}`;
            } else {
                requestBody = JSON.stringify(params);
            }

            // 3. 发送请求
            const response = await fetch(requestUrl, {
                method,
                headers: {
                    'Content-Type': 'application/json',
                    'timestamp': timestamp.toString(),
                    'nonce': nonce,
                    'sign': sign
                },
                body: requestBody
            });

            // 4. 处理响应
            const result = await response.json();
            if (!response.ok) {
                throw new Error(result.message || '请求失败');
            }
            return result;
        } catch (error) {
            console.error('请求异常:', error);
            throw error;
        }
    },

    // 简化的GET请求
    get(url, params) {
        return this.request('GET', url, params);
    },

    // 简化的POST请求
    post(url, params) {
        return this.request('POST', url, params);
    }
};

// 使用示例
async function fetchPortalData() {
    try {
        const result = await PortalApi.get('/api/portal/home/data', {
            page: 1,
            size: 10,
            type: 'news'
        });
        console.log('请求成功:', result);
    } catch (error) {
        console.error('请求失败:', error.message);
    }
}

// 调用示例
fetchPortalData();

三、关键说明

  1. 签名一致性:前后端必须使用相同的签名规则(参数排序、源串拼接、加密算法),否则会验证失败。
  2. 安全增强
    • 后端:通过 Redis 存储 nonce 防止重复请求,设置 5 分钟有效期避免内存溢出。
    • 前端:密钥需通过环境变量或混淆处理,避免直接暴露在代码中;生产环境必须使用 HTTPS。
  3. 参数处理
    • GET 请求参数从 Query 提取,POST 请求需解析 Body(如 JSON 参数),示例中简化了 POST 参数处理,实际需根据Content-Type解析。
    • 复杂参数(如数组、对象)需统一序列化格式(如 JSON.stringify),确保前后端一致。
  4. 加密算法:示例用 MD5 简化,生产环境建议使用 SHA256,前端可借助crypto-js库实现。

通过这套机制,每次请求的签名都是动态生成的(依赖时间戳和随机数),且无法重复使用,有效防止固定 token 被滥用的风险。

再来个java测试类,可以方便测试签名

import com.pantech.webApi.util.SignUtil;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;

/**
 * 签名测试工具类:生成测试用的签名和请求头
 */
public class SignTestUtil {
    
    // 生成随机字符串作为nonce
    private static String generateNonce() {
        String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
        Random random = new Random();
        StringBuilder sb = new StringBuilder(16);
        for (int i = 0; i < 16; i++) {
            int index = random.nextInt(chars.length());
            sb.append(chars.charAt(index));
        }
        return sb.toString();
    }
    
    /**
     * 生成测试用的签名和请求头信息
     * @param method 请求方法(GET/POST等)
     * @param url 请求路径(如/api/data)
     * @param params 请求参数
     * @return 包含签名信息的Map,key包括timestamp、nonce、sign
     */
    public static Map<String, String> generateTestSignInfo(String method, String url, Map<String, Object> params) {
        // 生成当前时间戳(秒)
        long timestamp = System.currentTimeMillis() / 1000;
        // 生成随机字符串
        String nonce = generateNonce();
        // 生成签名
        String sign = SignUtil.generateSign(method, url, params, timestamp, nonce);
        
        // 封装结果
        Map<String, String> signInfo = new HashMap<>();
        signInfo.put("timestamp", String.valueOf(timestamp));
        signInfo.put("nonce", nonce);
        signInfo.put("sign", sign);
        
        return signInfo;
    }
    
    /**
     * 打印测试用的请求头信息
     * @param method 请求方法
     * @param url 请求路径
     * @param params 请求参数
     */
    public static void printTestHeaders(String method, String url, Map<String, Object> params) {
        Map<String, String> signInfo = generateTestSignInfo(method, url, params);
        
        System.out.println("===== 测试请求头信息 =====");
        System.out.println("请求方法: " + method);
        System.out.println("请求URL: " + url);
        System.out.println("请求参数: " + params);
        System.out.println("-------------------------");
        System.out.println("timestamp: " + signInfo.get("timestamp"));
        System.out.println("nonce: " + signInfo.get("nonce"));
        System.out.println("sign: " + signInfo.get("sign"));
        System.out.println("=========================");
    }
    
    // 示例用法
    public static void main(String[] args) {
        // 测试GET请求
        String getMethod = "GET";
        String getUrl = "/api/user/list";
        Map<String, Object> getParams = new HashMap<>();
        getParams.put("page", 1);
        getParams.put("size", 10);
        getParams.put("status", "active");
        System.out.println("--- GET请求测试 ---");
        printTestHeaders(getMethod, getUrl, getParams);
        
        // 测试POST请求
        String postMethod = "POST";
        String postUrl = "/api/user/create";
        Map<String, Object> postParams = new HashMap<>();
        postParams.put("username", "testuser");
        postParams.put("email", "test@example.com");
        postParams.put("age", 30);
        System.out.println("\n--- POST请求测试 ---");
        printTestHeaders(postMethod, postUrl, postParams);
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值