简介
我们在设计接口的时候,最怕一个接口被用户截取用于重放攻击。重放攻击是什么呢?就是把你的请求原封不动地再发送一次,两次...n次,重放攻击是二次请求,黑客通过抓包获取到了请求的HTTP报文,然后黑客自己编写了一个类似的HTTP请求,发送给服务器。也就是说服务器处理了两个请求,先处理了正常的HTTP请求,然后又处理了黑客发送的篡改过的HTTP请求。
如果这个正常逻辑是插入数据库操作,那么一旦插入数据库的语句写的不好,就有可能出现多条重复的数据。一旦是比较慢的查询操作,就可能导致数据库堵住等情况。
实现流程
- 接受请求,判断头信息(timestamp、nonce、signature、key)是否都存在
- 判断时间戳timestamp是否已经大于过期时间(60s)
- 判断缓存中nonce是否存在,不存在则创建nonce
- 将请求数据+nonce+stamp+key使用md5加密,然后和signature比对,判断是否一致
- 任一条件不成立,则视为重放请求
使用 timestamp+nonce+signature
每个请求的请求头中,都需要携带timestamp+nonce+signature的信息,否则将验证不通过
timestamp
每次HTTP请求,都需要加上timestamp头信息。因为一次正常的HTTP请求,从发出到达服务器一般都不会超过60s,所以服务器收到HTTP请求之后,首先判断时间戳参数与当前时间相比较,是否超过了60s,如果超过了则认为是非法的请求。
var requestTime = DateTimeHelper.StampToDateTime(headers["X-CA-TIMESTAMP"].ToString());
if ((DateTime.Now - requestTime).Seconds >= 60)
return false;
nonce
nonce是仅一次有效的随机字符串,要求每次请求时,该参数要保证不同,我们将每次请求的nonce参数存储到一个缓存中。 每次处理HTTP请求时,首先判断该请求的nonce参数是否在缓存中,如果存在则认为是非法请求。
var nonce = cache.Get<string>($"nonce-{requestTime}");
if (!string.IsNullOrEmpty(nonce) && nonce.Equals(headers["X-CA-NONCE"].ToString()))
return false;
cache.Set($"nonce-{requestTime}", nonce, TimeSpan.FromMinutes(10));
nonce参数在首次请求时,已经被存储到了服务器上的缓存中,再次发送请求会被识别并拒绝。
nonce参数作为数字签名的一部分,是无法篡改的,因为别人不清楚token,所以不能生成新的signature。
signature
signature 是将请求的数据、key等信息加密,然后和传入的值比对是否一致,来判断请求是否被篡改或者是重复的请求
创建signature流程
- 获取
QueryString
参数,然后将键值对加入到Dictionary
中 - 获取
Body
中的参数,加入到Dictionary
中 - 然后将key加入到
Dictionary
中 - 按照a~z排序
- 将
Dictionary
转化为keyvalue相连的字符串 - 使用MD5加密
var requestData = new SortedDictionary<string, object>();
var queryString = request.QueryString;
if (queryString.HasValue)
{
var queryArray = queryString.Value.TrimStart('?').Split('&');
foreach (var item in queryArray)
{
if (!item.Contains('=')) continue;
var newArray = item.Split('=');
if (string.IsNullOrEmpty(newArray[1]))
continue;
if (requestData.ContainsKey(newArray[0])) continue;
requestData.Add(newArray[0], newArray[1]);
}
}
requestData.Add("key", request.Headers["X-CA-Key"].ToString());
requestData.Add("nonce", request.Headers["X-CA-NONCE"].ToString());
requestData.Add("timestamp", request.Headers["X-CA-TIMESTAMP"].ToString());
var body = await request.Body.GetStringAsync();
if (string.IsNullOrEmpty(body)) return requestData;
var dic = body.ToModel<SortedDictionary<string, object>>();
foreach (var item in dic)
{
if (requestData.ContainsKey(item.Key)) continue;
requestData.Add(item.Key, item.Value);
}
转化为字符串
string str = "";
foreach (var item in requestData)
{
str += item.Key + item.Value;
}
注意:要读取请求体Body中的内容,需要在中间件中开启
context.Request.EnableBuffering()
因为.net core在设计时,默认只能读取一次Body的内容,之后将读取不到Body中Stream
中的内容,并且在都去前后,都需要将Stream
中的Position
置为0,否着也读取不到内容
做成过滤器的完整实现
/// <summary>
/// 防重放 过滤(验证请求)
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AntiReplayFilter : ActionFilterAttribute
{
private IMemoryCache _cache;
private ILogger<AntiReplayFilter> _logger;
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var ignore = IgnoreVerify(context);
if (!ignore)
{
var request = context.HttpContext.Request;
_cache = context.HttpContext.RequestServices.GetService(typeof(IMemoryCache)) as IMemoryCache;
_logger = context.HttpContext.RequestServices.GetService(typeof(ILogger<AntiReplayFilter>)) as ILogger<AntiReplayFilter>;
var verifyHeader = AntiReplayHelper.VerifyHeader(request, _cache);
if (!verifyHeader)
{
ResponseHandle(context, "Incorrect request header");
return;
}
var dataDic = await AntiReplayHelper.GetRequestData(request);
var dataStr = AntiReplayHelper.DicToString(dataDic);
var encode = HttpUtility.UrlEncode(dataStr)?.ToLower().Replace("+","%20"); // URL Encode
var sign = SecretHelper.Md5(encode);
_logger.LogInformation($"request json data:{encode}, generate sign:{sign}, request sign:{request.Headers["X-CA-SIGNATURE"].ToString()}");
if (!sign.Equals(request.Headers["X-CA-SIGNATURE"].ToString(), StringComparison.OrdinalIgnoreCase))
{
ResponseHandle(context, "Incorrect signature");
return;
}
}
await base.OnActionExecutionAsync(context, next);
}
private void ResponseHandle(ActionExecutingContext context, string msg)
{
var result = new
{
success = false,
message = msg
};
context.Result = new BadRequestObjectResult(result);
}
private bool IgnoreVerify(ActionExecutingContext context)
{
var controllerActionDescriptor = context.ActionDescriptor as ControllerActionDescriptor;
if (controllerActionDescriptor == null) return true;
var ignore = controllerActionDescriptor.MethodInfo.GetCustomAttributes(true)
.Any(p => p.GetType() == typeof(IgnoreAntiReplayAttribute));
return ignore;
}
}
注意:需要将获取到的数据进行
UrlEncode
处理,Get
请求中如果有中文参数之类的,在编码的时候,js和C#大写小不一致,且空格C#会处理成+
号,这是一处暗坑
AntiReplayHelper
文件
/// <summary>
/// 防重放 辅助类
/// </summary>
internal class AntiReplayHelper
{
/// <summary>
/// 验证请求头参数
/// </summary>
/// <param name="request"></param>
/// <param name="cache"></param>
/// <returns></returns>
internal static bool VerifyHeader(HttpRequest request, IMemoryCache cache)
{
var headers = request.Headers;
if (string.IsNullOrEmpty(headers["X-CA-Key"]))
return false;
if (string.IsNullOrEmpty(headers["X-CA-NONCE"]))
return false;
if (string.IsNullOrEmpty(headers["X-CA-SIGNATURE"]))
return false;
if (string.IsNullOrEmpty(headers["X-CA-TIMESTAMP"]))
return false;
var requestTime = DateTimeHelper.StampToDateTime(headers["X-CA-TIMESTAMP"].ToString());
if ((DateTime.Now - requestTime).Minutes >= 1)
return false;
var nonce = cache.Get<string>($"nonce-{requestTime}");
if (!string.IsNullOrEmpty(nonce) && nonce.Equals(headers["X-CA-NONCE"].ToString()))
return false;
cache.Set($"nonce-{requestTime}", nonce, TimeSpan.FromMinutes(5));
return true;
}
/// <summary>
/// 获取请求参数,并根据字母(a~z)排序
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
internal static async Task<SortedDictionary<string, object>> GetRequestData(HttpRequest request)
{
var requestData = GetRequestQueryData(request);
if (request.HasFormContentType)
{
GetRequestFormData(request, requestData);
}
else
{
await GetRequestBodyData(request, requestData);
}
return requestData;
}
/// <summary>
/// 将Dictionary拼接成字符串
/// </summary>
/// <param name="data"></param>
/// <returns></returns>
internal static string DicToString(SortedDictionary<string, object> data)
{
string str = "";
foreach (var item in data)
{
str += item.Key + item.Value;
}
return str;
}
#region Utils
/// <summary>
/// 获取 QueryString
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
private static SortedDictionary<string, object> GetRequestQueryData(HttpRequest request)
{
var requestData = new SortedDictionary<string, object>();
var queryString = request.QueryString;
if (queryString.HasValue)
{
var queryArray = queryString.Value.TrimStart('?').Split('&');
foreach (var item in queryArray)
{
if (!item.Contains('=')) continue;
var newArray = item.Split('=');
if (string.IsNullOrEmpty(newArray[1]))
continue;
if (requestData.ContainsKey(newArray[0])) continue;
requestData.Add(newArray[0], newArray[1]);
}
}
requestData.Add("key", request.Headers["X-CA-Key"].ToString());
requestData.Add("nonce", request.Headers["X-CA-NONCE"].ToString());
requestData.Add("timestamp", request.Headers["X-CA-TIMESTAMP"].ToString());
return requestData;
}
/// <summary>
/// 获取Request Body中的数据
/// </summary>
/// <param name="request"></param>
/// <param name="requestData"></param>
/// <returns></returns>
private static async Task GetRequestBodyData(HttpRequest request, SortedDictionary<string, object> requestData)
{
try
{
// 请求流内容只能读取一次,配置 PostBodyHandlerMiddleware 中间件后可以多次读取,
// 但Position必须为0才能读取到内容,读取完成之后,从新将 Position 设置为 0,便于之后的中间件读取或模型绑定
if (request.Body.Position > 0) request.Body.Position = 0;
var readerStream = new StreamReader(request.Body);
var body = await readerStream.ReadToEndAsync();
request.Body.Position = 0;
if (string.IsNullOrEmpty(body)) return;
var dic = body.ToModel<SortedDictionary<string, object>>();
foreach (var item in dic)
{
if (requestData.ContainsKey(item.Key)) continue;
requestData.Add(item.Key, item.Value);
}
}
catch
{
throw new CustomException("Incorrect Request Body");
}
}
/// <summary>
/// 获取 Request Form中的数据
/// </summary>
/// <param name="request"></param>
/// <param name="requestData"></param>
private static void GetRequestFormData(HttpRequest request, SortedDictionary<string, object> requestData)
{
if (request.Form == null) return;
foreach (var item in request.Form)
{
requestData.Add(item.Key, item.Value);
}
}
PostBodyHandlerMiddleware
中间件:
/// <summary>
/// 开启 重复读取 请求流内容
/// </summary>
public class PostBodyHandlerMiddleware
{
private readonly RequestDelegate _next;
public PostBodyHandlerMiddleware(RequestDelegate next) => _next = next;
public async Task Invoke(HttpContext context)
{
context.Request.EnableBuffering();
await _next(context);
}
}
在Vue的axios请求中的实现
import md5 from 'js-md5'
//生成随机字符串(uuid)
const generateNonce = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0,
v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// 生成时间戳
const generateStamp = () => {
return Math.round(new Date() / 1000);
}
// 获取body中的数据,并去掉值为""和null的项
const getBody = (data) => {
if (!data) return data;
if (data instanceof FormData) {
let entries = data.entries();
let item = null;
let formData = {};
while(item = entries.next()){
if(item.done) break;
formData[item.value[0]] = item.value[1];
}
return formData;
}
for (var key in data) {
if (!data[key]) {
delete data[key]
}
}
return data;
}
// 获取url中的数据,并去掉值为""和null的项
const getQuery = (url) => {
let data = {};
if (url.indexOf('?') < 0) return data;
let queryString = url.split('?')[1];
let array = queryString.split('&');
for (let i = 0; i < array.length; i++) {
let newArr = array[i].split('=');
if (!newArr[1]) continue;
data[newArr[0]] = newArr[1];
}
return data;
}
//a-z排序,并化作string
const objectToString = (obj) => {
let str = '';
var newkey = Object.keys(obj).sort();
for (var i = 0; i < newkey.length; i++) {
str += newkey[i] + obj[newkey[i]];
}
return str;
}
// 生成签名
const generateSignature = (config, key, nonce, timestamp) => {
let data = { key: key, nonce: nonce, timestamp: timestamp };
let body = Object.assign(data, getBody(config.data), getBody(config.params), getQuery(config.url));
let str = encodeURIComponent(objectToString(body)).toLowerCase();
return md5(str);
}
const antiReplay = {
nonce: () => generateNonce(),
stamp: () => generateStamp(),
signature: (config, key, nonce, timestamp) => generateSignature(config, key, nonce, timestamp)
}
export default antiReplay;
axios 拦截器中如下
//请求拦截器
instance.interceptors.request.use(
config => {
let key = "clientId";
let nonce = antiReplay.nonce();
let stamp = antiReplay.stamp();
config.headers.common['X-CA-Key'] = key;
config.headers.common['X-CA-NONCE'] = nonce;
config.headers.common['X-CA-TIMESTAMP'] = stamp;
config.headers.common['X-CA-SIGNATURE'] = antiReplay.signature(config, key, nonce, stamp);
return config;
},
error => {
console.log(error);
return Promise.reject(error);
})
示例
- 请求url:
http://api.com?param_a=1¶m_e=2¶m_b=
- Method: post
- Body
{
"param_z":"123",
"param_d":"bbb",
"param_c":""
}
- key、nonce、timestamp
key=f76fa058-c89b-47ba-a833-7a818ebe00c6
nonce=d89a8784-d04b-46c1-b9c3-7afee74a20bd
timestamp=1589089870
- 计算 signature
转化为字符串为:keyf76fa058-c89b-47ba-a833-7a818ebe00c6nonced89a8784-d04b-46c1-b9c3-7afee74a20bdparam_a1param_dbbbparam_e2param_z123timestamp1589089870
通过md5计算得到的signature为:09b2e54a923a0b259b31addfae90ca8a