对于无需登录的门户网站接口,既要避免固定 token 被滥用,又要实现动态鉴权,同时不影响用户体验(无需强制登录),可以采用以下几种鉴权方案,结合 “动态可变” 和 “轻量化” 特点设计:
一、时间戳 + 签名机制(推荐)
核心思路
客户端每次请求时,基于当前时间戳、请求参数、双方约定的密钥生成一个动态签名,服务端验证签名合法性和时间戳有效性(防止重放攻击)。
签名每次请求都不同(因时间戳变化),且无法伪造(依赖密钥),无需登录即可实现动态鉴权。
实现步骤
-
客户端生成请求参数:
- 固定参数:
timestamp
(当前时间戳,精确到秒,如1620000000
)、nonce
(随机字符串,如 UUID,防止重复请求)。 - 业务参数:接口所需的实际数据(如
page=1&size=10
)。 - 签名生成:将
timestamp + nonce + 业务参数(排序后)+ 密钥
拼接成字符串,通过 MD5 或 SHA256 加密生成sign
。
示例拼接规则(按参数名 ASCII 排序,避免顺序影响签名):
key=密钥&nonce=xxx&page=1&size=10×tamp=1620000000
→ 加密后得到sign
。 - 固定参数:
-
客户端请求:
将timestamp
、nonce
、sign
作为请求头(或参数)发送,例如:http
GET /api/portal/data HTTP/1.1 timestamp: 1620000000 nonce: 5f4dcc3b5aa765d61d8327deb882cf99 sign: a1b2c3d4e5f6...
-
服务端验证:
- 验证
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();
三、关键说明
- 签名一致性:前后端必须使用相同的签名规则(参数排序、源串拼接、加密算法),否则会验证失败。
- 安全增强:
- 后端:通过 Redis 存储 nonce 防止重复请求,设置 5 分钟有效期避免内存溢出。
- 前端:密钥需通过环境变量或混淆处理,避免直接暴露在代码中;生产环境必须使用 HTTPS。
- 参数处理:
- GET 请求参数从 Query 提取,POST 请求需解析 Body(如 JSON 参数),示例中简化了 POST 参数处理,实际需根据
Content-Type
解析。 - 复杂参数(如数组、对象)需统一序列化格式(如 JSON.stringify),确保前后端一致。
- GET 请求参数从 Query 提取,POST 请求需解析 Body(如 JSON 参数),示例中简化了 POST 参数处理,实际需根据
- 加密算法:示例用 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);
}
}