forked from xiongyuxing/tiku-backend.net
118 lines
5.0 KiB
C#
118 lines
5.0 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using StackExchange.Redis;
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.Metrics;
|
|
using Tiku.Application.Security;
|
|
|
|
namespace Tiku.Infrastructure.Security;
|
|
|
|
internal sealed class RedisSecurityStore(
|
|
IConnectionMultiplexer connection,
|
|
string environmentName,
|
|
ILogger<RedisSecurityStore> logger) : IRedisSecurityStore
|
|
{
|
|
private static readonly Meter Meter = new("Tiku.Security.Redis", "1.0.0");
|
|
private static readonly Counter<long> OperationCounter = Meter.CreateCounter<long>("tiku.redis.security.operations");
|
|
private static readonly Counter<long> RejectionCounter = Meter.CreateCounter<long>("tiku.redis.rate_limit.rejections");
|
|
private static readonly Counter<long> ErrorCounter = Meter.CreateCounter<long>("tiku.redis.security.errors");
|
|
private static readonly Histogram<double> ScriptDuration = Meter.CreateHistogram<double>(
|
|
"tiku.redis.lua.duration", "ms");
|
|
private const string ConsumeScript = """
|
|
local now = redis.call('TIME')
|
|
local nowMs = now[1] * 1000 + math.floor(now[2] / 1000)
|
|
local retryAfter = 0
|
|
for i = 1, #KEYS do
|
|
local current = tonumber(redis.call('GET', KEYS[i]) or '0')
|
|
local limit = tonumber(ARGV[(i - 1) * 2 + 1])
|
|
if current >= limit then
|
|
local ttl = redis.call('PTTL', KEYS[i])
|
|
if ttl > retryAfter then retryAfter = ttl end
|
|
end
|
|
end
|
|
if retryAfter > 0 then return {0, retryAfter} end
|
|
for i = 1, #KEYS do
|
|
local window = tonumber(ARGV[(i - 1) * 2 + 2])
|
|
local value = redis.call('INCR', KEYS[i])
|
|
if value == 1 then redis.call('PEXPIRE', KEYS[i], window) end
|
|
end
|
|
return {1, 0}
|
|
""";
|
|
|
|
private readonly string prefix = $"tiku:{Normalize(environmentName)}";
|
|
|
|
public bool IsConfigured => true;
|
|
|
|
public async Task<DistributedRateLimitResult> ConsumeAsync(
|
|
IReadOnlyCollection<DistributedRateLimitBucket> buckets,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (buckets.Count == 0)
|
|
{
|
|
return new DistributedRateLimitResult(true);
|
|
}
|
|
|
|
try
|
|
{
|
|
var started = Stopwatch.GetTimestamp();
|
|
var keys = buckets.Select(bucket => (RedisKey)$"{prefix}:rl:{bucket.Key}").ToArray();
|
|
var values = buckets
|
|
.SelectMany(bucket => new RedisValue[] { bucket.PermitLimit, (long)bucket.Window.TotalMilliseconds })
|
|
.ToArray();
|
|
var result = (RedisResult[])(await connection.GetDatabase()
|
|
.ScriptEvaluateAsync(ConsumeScript, keys, values).WaitAsync(cancellationToken))!;
|
|
var allowed = (long)result[0] == 1;
|
|
var retryMs = (long)result[1];
|
|
OperationCounter.Add(1, new KeyValuePair<string, object?>("operation", "rate_limit"));
|
|
ScriptDuration.Record(Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
|
if (!allowed)
|
|
{
|
|
RejectionCounter.Add(1);
|
|
}
|
|
return new DistributedRateLimitResult(
|
|
allowed,
|
|
retryMs > 0 ? TimeSpan.FromMilliseconds(retryMs) : null);
|
|
}
|
|
catch (Exception exception) when (exception is RedisException or TimeoutException)
|
|
{
|
|
ErrorCounter.Add(1, new KeyValuePair<string, object?>("operation", "rate_limit"));
|
|
logger.LogError(exception, "Redis security operation failed closed.");
|
|
throw new RedisSecurityUnavailableException(exception);
|
|
}
|
|
}
|
|
|
|
public async Task<bool> PingAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
await connection.GetDatabase().PingAsync().WaitAsync(cancellationToken);
|
|
OperationCounter.Add(1, new KeyValuePair<string, object?>("operation", "ping"));
|
|
return true;
|
|
}
|
|
catch (Exception exception) when (exception is RedisException or TimeoutException)
|
|
{
|
|
ErrorCounter.Add(1, new KeyValuePair<string, object?>("operation", "ping"));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string Normalize(string value) =>
|
|
string.Concat(value.Trim().ToLowerInvariant().Select(character =>
|
|
char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-'));
|
|
}
|
|
|
|
public sealed class NullRedisSecurityStore : IRedisSecurityStore
|
|
{
|
|
public bool IsConfigured => false;
|
|
|
|
public Task<DistributedRateLimitResult> ConsumeAsync(
|
|
IReadOnlyCollection<DistributedRateLimitBucket> buckets,
|
|
CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(new DistributedRateLimitResult(true));
|
|
|
|
public Task<bool> PingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
|
}
|
|
|
|
public sealed class RedisSecurityUnavailableException(Exception innerException)
|
|
: Exception("Redis security services are unavailable.", innerException);
|