92 lines
3.0 KiB
C#
92 lines
3.0 KiB
C#
using System.Collections.Concurrent;
|
||
using StackExchange.Redis;
|
||
|
||
namespace Yh.Platform.AppAuth;
|
||
|
||
/// <summary>
|
||
/// 防重放守卫:同一 (appKey, timestamp, nonce) 只允许提交一次,有效期同请求窗口(默认 5 分钟)。
|
||
/// </summary>
|
||
public interface IReplayGuard
|
||
{
|
||
/// <summary>true = 首次提交放行;false = 重复提交拒绝。</summary>
|
||
Task<bool> TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default);
|
||
}
|
||
|
||
/// <summary>进程内防重放(单实例部署或 Redis 未配置时使用)。</summary>
|
||
public sealed class MemoryReplayGuard : IReplayGuard
|
||
{
|
||
private readonly ConcurrentDictionary<string, DateTimeOffset> _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<bool> 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 _);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Redis 防重放:SETNX 键 <c>sign:replay:{appKey}:{timestamp}:{nonce}</c>,TTL = 请求窗口。
|
||
/// </summary>
|
||
public sealed class RedisReplayGuard : IReplayGuard
|
||
{
|
||
private readonly Func<IDatabase?> _redisDatabaseFactory;
|
||
private readonly long _windowMs;
|
||
|
||
public RedisReplayGuard(Func<IDatabase?> redisDatabaseFactory, long windowMs = SignUtil.DefaultExpireMs)
|
||
{
|
||
_redisDatabaseFactory = redisDatabaseFactory;
|
||
_windowMs = windowMs;
|
||
}
|
||
|
||
public async Task<bool> 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);
|
||
}
|
||
}
|