using System.Collections.Concurrent;
using StackExchange.Redis;
namespace Yh.Platform.AppAuth;
///
/// 防重放守卫:同一 (appKey, timestamp, nonce) 只允许提交一次,有效期同请求窗口(默认 5 分钟)。
///
public interface IReplayGuard
{
/// true = 首次提交放行;false = 重复提交拒绝。
Task TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default);
}
/// 进程内防重放(单实例部署或 Redis 未配置时使用)。
public sealed class MemoryReplayGuard : IReplayGuard
{
private readonly ConcurrentDictionary _seen = new(StringComparer.Ordinal);
private readonly long _windowMs;
private DateTimeOffset _lastSweep = DateTimeOffset.MinValue;
private readonly object _sweepLock = new();
public MemoryReplayGuard(long windowMs = SignUtil.DefaultExpireMs)
{
_windowMs = windowMs;
}
public Task TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default)
{
Sweep();
var key = appKey + ":" + timestamp + ":" + nonce;
var now = DateTimeOffset.UtcNow;
var expiry = now.AddMilliseconds(_windowMs);
// 原子插入:首次出现即放行;已存在且未过期 → 拒绝;已过期 → 允许覆盖重放窗口
if (!_seen.TryAdd(key, expiry))
{
if (_seen.TryGetValue(key, out var existing) && existing > now)
{
return Task.FromResult(false);
}
_seen[key] = expiry;
}
return Task.FromResult(true);
}
private void Sweep()
{
var now = DateTimeOffset.UtcNow;
lock (_sweepLock)
{
if (now - _lastSweep < TimeSpan.FromSeconds(30))
{
return;
}
_lastSweep = now;
}
foreach (var kv in _seen)
{
if (kv.Value <= now)
{
_seen.TryRemove(kv.Key, out _);
}
}
}
}
///
/// Redis 防重放:SETNX 键 sign:replay:{appKey}:{timestamp}:{nonce},TTL = 请求窗口。
///
public sealed class RedisReplayGuard : IReplayGuard
{
private readonly Func _redisDatabaseFactory;
private readonly long _windowMs;
public RedisReplayGuard(Func redisDatabaseFactory, long windowMs = SignUtil.DefaultExpireMs)
{
_redisDatabaseFactory = redisDatabaseFactory;
_windowMs = windowMs;
}
public async Task TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default)
{
var redis = _redisDatabaseFactory();
if (redis == null)
{
return true; // Redis 未配置时不拦截
}
var key = "sign:replay:" + appKey + ":" + timestamp + ":" + nonce;
return await redis.StringSetAsync(key, "1", TimeSpan.FromMilliseconds(_windowMs), When.NotExists);
}
}