Added extra token security and token rateLimiting
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace EonaCat.SecureToken.Core
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
/// <summary>
|
||||
/// Detailed information about a token, extracted without signature verification.
|
||||
/// Use only for diagnostic and audit purposes, never for authorization.
|
||||
/// </summary>
|
||||
public sealed class TokenIntrospectionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the token format is valid (can be parsed).
|
||||
/// </summary>
|
||||
public bool IsValidFormat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The token claims if the format is valid; otherwise null.
|
||||
/// </summary>
|
||||
public TokenClaims? Claims { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if parsing failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional diagnostic information.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Diagnostics { get; set; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful introspection result.
|
||||
/// </summary>
|
||||
public static TokenIntrospectionResult Success(TokenClaims claims)
|
||||
{
|
||||
return new TokenIntrospectionResult
|
||||
{
|
||||
IsValidFormat = true,
|
||||
Claims = claims,
|
||||
ErrorMessage = null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failure introspection result.
|
||||
/// </summary>
|
||||
public static TokenIntrospectionResult Failure(string errorMessage)
|
||||
{
|
||||
return new TokenIntrospectionResult
|
||||
{
|
||||
IsValidFormat = false,
|
||||
Claims = null,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides detailed token introspection for diagnostic and audit purposes.
|
||||
/// This is useful for debugging token issues and understanding token structure.
|
||||
/// </summary>
|
||||
public interface ITokenIntrospector
|
||||
{
|
||||
/// <summary>
|
||||
/// Analyzes a token without verifying the signature.
|
||||
/// Returns detailed information about the token structure and claims.
|
||||
/// Use only for diagnostics - never for authorization decisions.
|
||||
/// </summary>
|
||||
/// <param name="token">The token to introspect</param>
|
||||
/// <returns>Introspection result with claims or error information</returns>
|
||||
TokenIntrospectionResult Introspect(string token);
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes multiple tokens and returns their information.
|
||||
/// </summary>
|
||||
/// <param name="tokens">Tokens to introspect</param>
|
||||
/// <returns>Introspection results for each token</returns>
|
||||
IReadOnlyList<TokenIntrospectionResult> IntrospectMultiple(params string[] tokens);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts and analyzes the raw token parts (header, payload, signature) for debugging.
|
||||
/// </summary>
|
||||
/// <param name="token">The token to analyze</param>
|
||||
/// <param name="encodedPayload">The Base64-encoded payload if parsing succeeds</param>
|
||||
/// <param name="encodedSignature">The Base64-encoded signature if parsing succeeds</param>
|
||||
/// <returns>True if the token format is valid; false otherwise</returns>
|
||||
bool TryGetRawParts(string token, out string? encodedPayload, out string? encodedSignature);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a human-readable summary of token claims for debugging.
|
||||
/// </summary>
|
||||
/// <param name="token">The token to summarize</param>
|
||||
/// <returns>Formatted summary string</returns>
|
||||
string GetClaimsSummary(string token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of token introspection for diagnostics.
|
||||
/// </summary>
|
||||
public sealed class TokenIntrospector : ITokenIntrospector
|
||||
{
|
||||
private readonly ITokenService _tokenService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="TokenIntrospector"/>.
|
||||
/// </summary>
|
||||
/// <param name="tokenService">The token service to use for introspection</param>
|
||||
public TokenIntrospector(ITokenService tokenService)
|
||||
{
|
||||
_tokenService = tokenService ?? throw new ArgumentNullException(nameof(tokenService));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TokenIntrospectionResult Introspect(string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return TokenIntrospectionResult.Failure("Token cannot be null or empty.");
|
||||
}
|
||||
|
||||
var claims = _tokenService.Inspect(token);
|
||||
if (claims is null)
|
||||
{
|
||||
return TokenIntrospectionResult.Failure("Token format is invalid or payload could not be decoded.");
|
||||
}
|
||||
|
||||
var result = TokenIntrospectionResult.Success(claims);
|
||||
|
||||
// Add diagnostic info
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
result.Diagnostics["IsExpired"] = (now > claims.ExpiresAt).ToString();
|
||||
result.Diagnostics["ExpiresAt"] = claims.ExpiresAt.ToString("O");
|
||||
result.Diagnostics["IssuedAt"] = claims.IssuedAt.ToString("O");
|
||||
result.Diagnostics["TokenAge"] = (now - claims.IssuedAt).TotalSeconds.ToString("F0");
|
||||
result.Diagnostics["TokenType"] = claims.TokenType;
|
||||
result.Diagnostics["KeyGeneration"] = claims.KeyGeneration.ToString();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<TokenIntrospectionResult> IntrospectMultiple(params string[] tokens)
|
||||
{
|
||||
if (tokens is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(tokens));
|
||||
}
|
||||
|
||||
var results = new List<TokenIntrospectionResult>(tokens.Length);
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
results.Add(Introspect(token));
|
||||
}
|
||||
|
||||
return results.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetRawParts(string token, out string? encodedPayload, out string? encodedSignature)
|
||||
{
|
||||
encodedPayload = null;
|
||||
encodedSignature = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
encodedPayload = parts[1];
|
||||
encodedSignature = parts[2];
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetClaimsSummary(string token)
|
||||
{
|
||||
var result = Introspect(token);
|
||||
if (!result.IsValidFormat || result.Claims is null)
|
||||
{
|
||||
return $"INVALID: {result.ErrorMessage}";
|
||||
}
|
||||
|
||||
var claims = result.Claims;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var isExpired = now > claims.ExpiresAt;
|
||||
var timeRemaining = claims.ExpiresAt - now;
|
||||
|
||||
var summary = new System.Text.StringBuilder();
|
||||
summary.AppendLine("=== Token Claims Summary ===");
|
||||
summary.AppendLine($"Token ID: {claims.TokenId}");
|
||||
summary.AppendLine($"Subject: {claims.Subject}");
|
||||
summary.AppendLine($"Issuer: {claims.Issuer}");
|
||||
summary.AppendLine($"Audiences: {string.Join(", ", claims.Audiences)}");
|
||||
summary.AppendLine($"Token Type: {claims.TokenType}");
|
||||
summary.AppendLine($"Key Generation: {claims.KeyGeneration}");
|
||||
summary.AppendLine();
|
||||
summary.AppendLine($"Issued At: {claims.IssuedAt:O}");
|
||||
summary.AppendLine($"Not Before: {claims.NotBefore:O}");
|
||||
summary.AppendLine($"Expires At: {claims.ExpiresAt:O}");
|
||||
summary.AppendLine($"Status: {(isExpired ? "EXPIRED" : "VALID")}");
|
||||
if (!isExpired)
|
||||
{
|
||||
summary.AppendLine($"Expires In: {timeRemaining.TotalSeconds:F0}s ({timeRemaining.TotalMinutes:F2} min)");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(claims.BindingContext))
|
||||
{
|
||||
summary.AppendLine($"Binding Context: {claims.BindingContext}");
|
||||
}
|
||||
|
||||
if (claims.Roles.Count > 0)
|
||||
{
|
||||
summary.AppendLine($"Roles: {string.Join(", ", claims.Roles)}");
|
||||
}
|
||||
|
||||
if (claims.Custom.Count > 0)
|
||||
{
|
||||
summary.AppendLine("Custom Claims:");
|
||||
foreach (var claim in claims.Custom)
|
||||
{
|
||||
summary.AppendLine($" {claim.Key}: {claim.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
return summary.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
|
||||
<Copyright>EonaCat (Jeroen Saey)</Copyright>
|
||||
<PackageTags>EonaCat;authentication;api;micro;services;microservices;rotation;secure;token;validation;jwt;security;secure;token;paseto;security;auth;jwt-alternative;Jeroen;Saey</PackageTags>
|
||||
<PackageIconUrl />
|
||||
<FileVersion>0.0.3</FileVersion>
|
||||
<FileVersion>0.0.4</FileVersion>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||
@@ -25,7 +25,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<EVRevisionFormat>0.0.3+{chash:10}.{c:ymd}</EVRevisionFormat>
|
||||
<EVRevisionFormat>0.0.4+{chash:10}.{c:ymd}</EVRevisionFormat>
|
||||
<EVDefault>true</EVDefault>
|
||||
<EVInfo>true</EVInfo>
|
||||
<EVTagMatch>v[0-9]*</EVTagMatch>
|
||||
@@ -36,7 +36,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<Version>0.0.3</Version>
|
||||
<Version>0.0.4</Version>
|
||||
<PackageId>EonaCat.SecureToken</PackageId>
|
||||
<Product>EonaCat.SecureToken</Product>
|
||||
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.SecureToken</RepositoryUrl>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using EonaCat.SecureToken.Cryptography;
|
||||
using EonaCat.SecureToken.Validation;
|
||||
using EonaCat.SecureToken.Core;
|
||||
using EonaCat.SecureToken.Security;
|
||||
using System;
|
||||
|
||||
namespace EonaCat.SecureToken.Extensions
|
||||
@@ -10,6 +12,7 @@ namespace EonaCat.SecureToken.Extensions
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering SecureToken services with the DI container.
|
||||
/// Includes support for token service, introspection, rate limiting, blacklisting, and caching.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
@@ -65,5 +68,75 @@ namespace EonaCat.SecureToken.Extensions
|
||||
services.AddSingleton<IReplayCache, InMemoryReplayCache>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds token introspection service for diagnostic and audit purposes.
|
||||
/// Allows detailed examination of token claims without signature verification.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSecureTokenIntrospection(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ITokenIntrospector>(sp =>
|
||||
new TokenIntrospector(sp.GetRequiredService<ITokenService>()));
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds token rate limiting for failed validation attempt throttling.
|
||||
/// Protects against brute-force attacks on token validation.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection</param>
|
||||
/// <param name="policy">Optional rate limiting policy; uses defaults if null</param>
|
||||
public static IServiceCollection AddSecureTokenRateLimiting(
|
||||
this IServiceCollection services,
|
||||
RateLimitPolicy? policy = null)
|
||||
{
|
||||
services.AddSingleton<ITokenRateLimiter>(
|
||||
_ => new InMemoryTokenRateLimiter(policy));
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds token blacklist for revocation management.
|
||||
/// Supports immediate revocation of tokens before their natural expiration.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSecureTokenBlacklist(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ITokenBlacklist, InMemoryTokenBlacklist>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds token caching for reducing validation computational overhead.
|
||||
/// Validates tokens once and caches the result for frequent lookups.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSecureTokenCache(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ISecurityTokenCache, InMemorySecurityTokenCache>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds all security features (introspection, rate limiting, blacklist, and cache).
|
||||
/// Recommended for most production applications.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSecureTokenWithAllFeatures(this IServiceCollection services)
|
||||
{
|
||||
return services
|
||||
.AddSecureTokenIntrospection()
|
||||
.AddSecureTokenRateLimiting()
|
||||
.AddSecureTokenBlacklist()
|
||||
.AddSecureTokenCache();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a subset of security features (rate limiting and blacklist only).
|
||||
/// Suitable for high-performance scenarios where caching is not needed.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSecureTokenWithMinimalSecurity(this IServiceCollection services)
|
||||
{
|
||||
return services
|
||||
.AddSecureTokenRateLimiting()
|
||||
.AddSecureTokenBlacklist();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using EonaCat.SecureToken.Core;
|
||||
using EonaCat.SecureToken.Security;
|
||||
using EonaCat.SecureToken.Tokens;
|
||||
using EonaCat.SecureToken.Validation;
|
||||
|
||||
namespace EonaCat.SecureToken.Extensions
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for advanced token operations including batch processing,
|
||||
/// security checks, and validation with additional features.
|
||||
/// </summary>
|
||||
public static class TokenServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates multiple tokens concurrently.
|
||||
/// </summary>
|
||||
/// <param name="service">The token service</param>
|
||||
/// <param name="tokens">Tokens to validate</param>
|
||||
/// <param name="options">Validation options</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Collection of validation results in the same order as input tokens</returns>
|
||||
public static async Task<IReadOnlyList<TokenResult>> ValidateManyAsync(
|
||||
this ITokenService service,
|
||||
IEnumerable<string> tokens,
|
||||
TokenValidationOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (service is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(service));
|
||||
}
|
||||
|
||||
if (tokens is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(tokens));
|
||||
}
|
||||
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
var tokenList = tokens.ToList();
|
||||
var results = new List<TokenResult>(tokenList.Count);
|
||||
|
||||
foreach (var token in tokenList)
|
||||
{
|
||||
results.Add(await service.ValidateAsync(token, options, ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return results.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a token and checks the blacklist.
|
||||
/// </summary>
|
||||
/// <param name="service">The token service</param>
|
||||
/// <param name="token">Token to validate</param>
|
||||
/// <param name="options">Validation options</param>
|
||||
/// <param name="blacklist">The blacklist to check</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Validation result (or Revoked if token is blacklisted)</returns>
|
||||
public static async Task<TokenResult> ValidateWithBlacklistAsync(
|
||||
this ITokenService service,
|
||||
string token,
|
||||
TokenValidationOptions options,
|
||||
ITokenBlacklist blacklist,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (service is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(service));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
throw new ArgumentException("Token cannot be null or empty.", nameof(token));
|
||||
}
|
||||
|
||||
if (blacklist is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(blacklist));
|
||||
}
|
||||
|
||||
// First validate the token normally
|
||||
var result = await service.ValidateAsync(token, options, ct).ConfigureAwait(false);
|
||||
|
||||
// If validation succeeded, check blacklist
|
||||
if (result is TokenResult.Success success)
|
||||
{
|
||||
var isBlacklisted = await blacklist.IsBlacklistedAsync(success.Claims.TokenId).ConfigureAwait(false);
|
||||
if (isBlacklisted)
|
||||
{
|
||||
return new TokenResult.Revoked(success.Claims.TokenId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a token with rate limiting on the subject.
|
||||
/// </summary>
|
||||
/// <param name="service">The token service</param>
|
||||
/// <param name="token">Token to validate</param>
|
||||
/// <param name="options">Validation options</param>
|
||||
/// <param name="rateLimiter">The rate limiter</param>
|
||||
/// <param name="subject">Subject to rate limit (e.g., user ID, IP address)</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Validation result (or failure if rate limited)</returns>
|
||||
public static async Task<TokenResult> ValidateWithRateLimitingAsync(
|
||||
this ITokenService service,
|
||||
string token,
|
||||
TokenValidationOptions options,
|
||||
ITokenRateLimiter rateLimiter,
|
||||
string subject,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (service is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(service));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
throw new ArgumentException("Token cannot be null or empty.", nameof(token));
|
||||
}
|
||||
|
||||
if (rateLimiter is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(rateLimiter));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
throw new ArgumentException("Subject cannot be null or empty.", nameof(subject));
|
||||
}
|
||||
|
||||
// Check if subject is already rate limited
|
||||
var isLocked = await rateLimiter.IsLockedOutAsync(subject).ConfigureAwait(false);
|
||||
if (isLocked)
|
||||
{
|
||||
var timeRemaining = await rateLimiter.GetLockoutTimeRemainingAsync(subject).ConfigureAwait(false);
|
||||
return new TokenResult.Malformed(
|
||||
$"Rate limit exceeded. Try again in {timeRemaining?.TotalSeconds:F0} seconds.");
|
||||
}
|
||||
|
||||
// Validate the token
|
||||
var result = await service.ValidateAsync(token, options, ct).ConfigureAwait(false);
|
||||
|
||||
// If validation failed, record the failure
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
var recorded = await rateLimiter.TryRecordFailureAsync(subject).ConfigureAwait(false);
|
||||
if (!recorded)
|
||||
{
|
||||
return new TokenResult.Malformed("Rate limit exceeded for invalid token attempts.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Reset the failure count on successful validation
|
||||
await rateLimiter.ResetAsync(subject).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a token and caches the result for subsequent lookups.
|
||||
/// Reduces computational overhead for frequently-validated tokens.
|
||||
/// </summary>
|
||||
/// <param name="service">The token service</param>
|
||||
/// <param name="token">Token to validate</param>
|
||||
/// <param name="options">Validation options</param>
|
||||
/// <param name="cache">The cache to store validated tokens</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Validation result</returns>
|
||||
public static async Task<TokenResult> ValidateWithCachingAsync(
|
||||
this ITokenService service,
|
||||
string token,
|
||||
TokenValidationOptions options,
|
||||
ISecurityTokenCache cache,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (service is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(service));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
throw new ArgumentException("Token cannot be null or empty.", nameof(token));
|
||||
}
|
||||
|
||||
if (cache is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(cache));
|
||||
}
|
||||
|
||||
// Try to decode token without verification to get the token ID
|
||||
if (!TokenSerializer.TryDecode(token, out var payload, out _))
|
||||
{
|
||||
return new TokenResult.Malformed("Token format is invalid.");
|
||||
}
|
||||
|
||||
var claims = TokenSerializer.Deserialize(payload);
|
||||
if (claims is null)
|
||||
{
|
||||
return new TokenResult.Malformed("Token payload could not be decoded.");
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
var cached = await cache.TryGetAsync(claims.TokenId).ConfigureAwait(false);
|
||||
if (cached)
|
||||
{
|
||||
return new TokenResult.Success(token, claims);
|
||||
}
|
||||
|
||||
// If not in cache, validate normally
|
||||
var result = await service.ValidateAsync(token, options, ct).ConfigureAwait(false);
|
||||
|
||||
// If successful, add to cache
|
||||
if (result is TokenResult.Success success)
|
||||
{
|
||||
await cache.SetAsync(success.Claims.TokenId, success.Claims.ExpiresAt).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a token with all available security checks (rate limiting, blacklist, caching).
|
||||
/// </summary>
|
||||
/// <param name="service">The token service</param>
|
||||
/// <param name="token">Token to validate</param>
|
||||
/// <param name="options">Validation options</param>
|
||||
/// <param name="configuration">Security configuration with rate limiter, blacklist, and/or cache</param>
|
||||
/// <param name="rateLimitSubject">Optional subject for rate limiting (IP, user ID, etc.)</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Validation result with all security checks applied</returns>
|
||||
public static async Task<TokenResult> ValidateWithSecurityAsync(
|
||||
this ITokenService service,
|
||||
string token,
|
||||
TokenValidationOptions options,
|
||||
SecureTokenConfiguration configuration,
|
||||
string? rateLimitSubject = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (service is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(service));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
throw new ArgumentException("Token cannot be null or empty.", nameof(token));
|
||||
}
|
||||
|
||||
if (configuration is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
var result = await service.ValidateAsync(token, options, ct).ConfigureAwait(false);
|
||||
|
||||
// Check blacklist if configured
|
||||
if (configuration.Blacklist is not null && result is TokenResult.Success success)
|
||||
{
|
||||
var isBlacklisted = await configuration.Blacklist.IsBlacklistedAsync(
|
||||
success.Claims.TokenId).ConfigureAwait(false);
|
||||
if (isBlacklisted)
|
||||
{
|
||||
result = new TokenResult.Revoked(success.Claims.TokenId);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply rate limiting if configured
|
||||
if (configuration.RateLimiter is not null && !string.IsNullOrWhiteSpace(rateLimitSubject))
|
||||
{
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
var recorded = await configuration.RateLimiter.TryRecordFailureAsync(rateLimitSubject).ConfigureAwait(false);
|
||||
if (!recorded)
|
||||
{
|
||||
return new TokenResult.Malformed("Rate limit exceeded.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await configuration.RateLimiter.ResetAsync(rateLimitSubject).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache successful validations if configured
|
||||
if (configuration.Cache is not null && result is TokenResult.Success cacheSuccess)
|
||||
{
|
||||
await configuration.Cache.SetAsync(cacheSuccess.Claims.TokenId, cacheSuccess.Claims.ExpiresAt).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a summary of all failed validations for a subject during the current rate limit window.
|
||||
/// Useful for security auditing and debugging.
|
||||
/// </summary>
|
||||
/// <param name="rateLimiter">The rate limiter</param>
|
||||
/// <param name="subject">The subject to check</param>
|
||||
/// <returns>Summary information including failure count and lockout status</returns>
|
||||
public static async Task<string> GetFailureSummaryAsync(
|
||||
this ITokenRateLimiter rateLimiter,
|
||||
string subject)
|
||||
{
|
||||
if (rateLimiter is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(rateLimiter));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
throw new ArgumentException("Subject cannot be null or empty.", nameof(subject));
|
||||
}
|
||||
|
||||
var failureCount = await rateLimiter.GetFailureCountAsync(subject).ConfigureAwait(false);
|
||||
var lockoutTime = await rateLimiter.GetLockoutTimeRemainingAsync(subject).ConfigureAwait(false);
|
||||
|
||||
if (lockoutTime.HasValue)
|
||||
{
|
||||
return $"Subject '{subject}' is locked out. {failureCount} failures recorded. " +
|
||||
$"Time remaining: {lockoutTime.Value.TotalSeconds:F0} seconds.";
|
||||
}
|
||||
|
||||
return $"Subject '{subject}' has {failureCount} failures recorded (not locked out).";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Introspects and compares multiple tokens for diagnostic purposes.
|
||||
/// </summary>
|
||||
/// <param name="introspector">The token introspector</param>
|
||||
/// <param name="tokens">Tokens to compare</param>
|
||||
/// <returns>Formatted comparison of token claims</returns>
|
||||
public static string CompareTokens(
|
||||
this ITokenIntrospector introspector,
|
||||
params string[] tokens)
|
||||
{
|
||||
if (introspector is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(introspector));
|
||||
}
|
||||
|
||||
if (tokens is null || tokens.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one token must be provided.", nameof(tokens));
|
||||
}
|
||||
|
||||
var results = introspector.IntrospectMultiple(tokens);
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("=== Token Comparison ===");
|
||||
|
||||
for (int i = 0; i < results.Count; i++)
|
||||
{
|
||||
sb.AppendLine($"\n--- Token {i + 1} ---");
|
||||
if (results[i].IsValidFormat && results[i].Claims is not null)
|
||||
{
|
||||
var claims = results[i].Claims;
|
||||
sb.AppendLine($"Subject: {claims.Subject}");
|
||||
sb.AppendLine($"Issuer: {claims.Issuer}");
|
||||
sb.AppendLine($"Audiences: {string.Join(", ", claims.Audiences)}");
|
||||
sb.AppendLine($"Token Type: {claims.TokenType}");
|
||||
sb.AppendLine($"Expires: {(DateTimeOffset.UtcNow > claims.ExpiresAt ? "EXPIRED" : "VALID")}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"ERROR: {results[i].ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace EonaCat.SecureToken
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
/// <summary>
|
||||
/// Provides in-memory caching and lifecycle management for validated tokens.
|
||||
/// This interface allows for extensible cache implementations (in-memory, distributed, database, etc.).
|
||||
/// </summary>
|
||||
public interface ISecurityTokenCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a validated token in the cache.
|
||||
/// </summary>
|
||||
/// <param name="tokenId">The token identifier</param>
|
||||
/// <param name="expiresAt">When the token expires</param>
|
||||
Task SetAsync(string tokenId, DateTimeOffset expiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a token from the cache if it exists and has not expired.
|
||||
/// </summary>
|
||||
/// <param name="tokenId">The token identifier</param>
|
||||
/// <returns>True if the token is in cache and valid; otherwise false</returns>
|
||||
Task<bool> TryGetAsync(string tokenId);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a token from the cache (e.g., after revocation).
|
||||
/// </summary>
|
||||
/// <param name="tokenId">The token identifier</param>
|
||||
Task RemoveAsync(string tokenId);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all tokens from the cache. Returns the count of items removed.
|
||||
/// </summary>
|
||||
Task<int> ClearAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current count of cached tokens.
|
||||
/// </summary>
|
||||
Task<int> CountAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Removes all expired tokens from the cache. Returns the count of items removed.
|
||||
/// </summary>
|
||||
Task<int> RemoveExpiredAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe in-memory implementation of <see cref="ISecurityTokenCache"/>.
|
||||
/// Automatically removes expired tokens during operations and periodically.
|
||||
/// Suitable for single-server deployments; for distributed systems, use a distributed cache.
|
||||
/// </summary>
|
||||
public sealed class InMemorySecurityTokenCache : ISecurityTokenCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _cache =
|
||||
new ConcurrentDictionary<string, DateTimeOffset>();
|
||||
|
||||
private readonly object _cleanupLock = new object();
|
||||
private DateTimeOffset _lastCleanup = DateTimeOffset.UtcNow;
|
||||
private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="InMemorySecurityTokenCache"/>.
|
||||
/// </summary>
|
||||
public InMemorySecurityTokenCache()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetAsync(string tokenId, DateTimeOffset expiresAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
throw new ArgumentException("Token ID cannot be null or empty.", nameof(tokenId));
|
||||
}
|
||||
|
||||
_cache.AddOrUpdate(tokenId, expiresAt, (_, _) => expiresAt);
|
||||
TriggerCleanupIfNeeded();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TryGetAsync(string tokenId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (_cache.TryGetValue(tokenId, out var expiresAt))
|
||||
{
|
||||
if (DateTimeOffset.UtcNow <= expiresAt)
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
// Token has expired; remove it
|
||||
_cache.TryRemove(tokenId, out _);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveAsync(string tokenId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
_cache.TryRemove(tokenId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> ClearAsync()
|
||||
{
|
||||
var count = _cache.Count;
|
||||
_cache.Clear();
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountAsync()
|
||||
{
|
||||
return Task.FromResult(_cache.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> RemoveExpiredAsync()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expiredTokens = _cache
|
||||
.Where(kvp => now > kvp.Value)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
int removed = 0;
|
||||
foreach (var token in expiredTokens)
|
||||
{
|
||||
if (_cache.TryRemove(token, out _))
|
||||
{
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
private void TriggerCleanupIfNeeded()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (now - _lastCleanup >= _cleanupInterval)
|
||||
{
|
||||
lock (_cleanupLock)
|
||||
{
|
||||
// Double-check after acquiring lock
|
||||
if (now - _lastCleanup >= _cleanupInterval)
|
||||
{
|
||||
_lastCleanup = now;
|
||||
// Fire-and-forget cleanup
|
||||
_ = RemoveExpiredAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
|
||||
namespace EonaCat.SecureToken.Core
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
/// <summary>
|
||||
/// Represents an access token and refresh token pair.
|
||||
/// </summary>
|
||||
public sealed class AccessTokenRefreshTokenPair
|
||||
{
|
||||
/// <summary>
|
||||
/// The access token (short-lived, typically for API requests).
|
||||
/// </summary>
|
||||
public string AccessToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The refresh token (long-lived, used to obtain new access tokens).
|
||||
/// </summary>
|
||||
public string RefreshToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new token pair.
|
||||
/// </summary>
|
||||
public AccessTokenRefreshTokenPair(string accessToken, string refreshToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
throw new ArgumentException("Access token cannot be null or empty.", nameof(accessToken));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(refreshToken))
|
||||
{
|
||||
throw new ArgumentException("Refresh token cannot be null or empty.", nameof(refreshToken));
|
||||
}
|
||||
|
||||
AccessToken = accessToken;
|
||||
RefreshToken = refreshToken;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper for issuing paired access and refresh tokens with optimal defaults.
|
||||
/// </summary>
|
||||
public static class TokenPairDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Default lifetime for access tokens: 15 minutes.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultAccessTokenLifetime = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>
|
||||
/// Default lifetime for refresh tokens: 30 days.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultRefreshTokenLifetime = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an access token and refresh token pair with secure defaults.
|
||||
/// </summary>
|
||||
/// <param name="service">The token service</param>
|
||||
/// <param name="subject">The subject (user ID, etc.)</param>
|
||||
/// <param name="issuer">The issuer</param>
|
||||
/// <param name="audience">The audience</param>
|
||||
/// <param name="accessTokenLifetime">Custom access token lifetime; defaults to 15 minutes</param>
|
||||
/// <param name="refreshTokenLifetime">Custom refresh token lifetime; defaults to 30 days</param>
|
||||
/// <returns>A pair of access and refresh tokens</returns>
|
||||
public static AccessTokenRefreshTokenPair CreatePair(
|
||||
ITokenService service,
|
||||
string subject,
|
||||
string issuer,
|
||||
string audience,
|
||||
TimeSpan? accessTokenLifetime = null,
|
||||
TimeSpan? refreshTokenLifetime = null)
|
||||
{
|
||||
if (service is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(service));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
throw new ArgumentException("Subject cannot be null or empty.", nameof(subject));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(issuer))
|
||||
{
|
||||
throw new ArgumentException("Issuer cannot be null or empty.", nameof(issuer));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
throw new ArgumentException("Audience cannot be null or empty.", nameof(audience));
|
||||
}
|
||||
|
||||
var accessLifetime = accessTokenLifetime ?? DefaultAccessTokenLifetime;
|
||||
var refreshLifetime = refreshTokenLifetime ?? DefaultRefreshTokenLifetime;
|
||||
|
||||
var accessToken = service.Issue(
|
||||
TokenDescriptor.Create()
|
||||
.ForSubject(subject)
|
||||
.IssuedBy(issuer)
|
||||
.ForAudience(audience)
|
||||
.WithLifetime(accessLifetime));
|
||||
|
||||
var refreshToken = service.Issue(
|
||||
TokenDescriptor.Create()
|
||||
.ForSubject(subject)
|
||||
.IssuedBy(issuer)
|
||||
.ForAudience(audience)
|
||||
.AsRefreshToken()
|
||||
.WithLifetime(refreshLifetime));
|
||||
|
||||
return new AccessTokenRefreshTokenPair(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an access token and refresh token pair with custom claims and roles.
|
||||
/// </summary>
|
||||
/// <param name="service">The token service</param>
|
||||
/// <param name="subject">The subject (user ID, etc.)</param>
|
||||
/// <param name="issuer">The issuer</param>
|
||||
/// <param name="audience">The audience</param>
|
||||
/// <param name="configure">Optional action to configure additional claims and roles</param>
|
||||
/// <param name="accessTokenLifetime">Custom access token lifetime; defaults to 15 minutes</param>
|
||||
/// <param name="refreshTokenLifetime">Custom refresh token lifetime; defaults to 30 days</param>
|
||||
/// <returns>A pair of access and refresh tokens</returns>
|
||||
public static AccessTokenRefreshTokenPair CreatePair(
|
||||
ITokenService service,
|
||||
string subject,
|
||||
string issuer,
|
||||
string audience,
|
||||
Action<TokenDescriptor>? configure = null,
|
||||
TimeSpan? accessTokenLifetime = null,
|
||||
TimeSpan? refreshTokenLifetime = null)
|
||||
{
|
||||
if (service is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(service));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
throw new ArgumentException("Subject cannot be null or empty.", nameof(subject));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(issuer))
|
||||
{
|
||||
throw new ArgumentException("Issuer cannot be null or empty.", nameof(issuer));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
throw new ArgumentException("Audience cannot be null or empty.", nameof(audience));
|
||||
}
|
||||
|
||||
var accessLifetime = accessTokenLifetime ?? DefaultAccessTokenLifetime;
|
||||
var refreshLifetime = refreshTokenLifetime ?? DefaultRefreshTokenLifetime;
|
||||
|
||||
var baseAccessDescriptor = TokenDescriptor.Create()
|
||||
.ForSubject(subject)
|
||||
.IssuedBy(issuer)
|
||||
.ForAudience(audience)
|
||||
.WithLifetime(accessLifetime);
|
||||
|
||||
configure?.Invoke(baseAccessDescriptor);
|
||||
|
||||
var accessToken = service.Issue(baseAccessDescriptor);
|
||||
|
||||
// Refresh token uses same claims (roles, custom claims) but longer lifetime
|
||||
var baseRefreshDescriptor = TokenDescriptor.Create()
|
||||
.ForSubject(subject)
|
||||
.IssuedBy(issuer)
|
||||
.ForAudience(audience)
|
||||
.AsRefreshToken()
|
||||
.WithLifetime(refreshLifetime);
|
||||
|
||||
configure?.Invoke(baseRefreshDescriptor);
|
||||
|
||||
var refreshToken = service.Issue(baseRefreshDescriptor);
|
||||
|
||||
return new AccessTokenRefreshTokenPair(accessToken, refreshToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System;
|
||||
using EonaCat.SecureToken.Security;
|
||||
|
||||
namespace EonaCat.SecureToken
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for SecureToken with security and caching features.
|
||||
/// </summary>
|
||||
public sealed class SecureTokenConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the token rate limiter for failed validation attempt throttling.
|
||||
/// </summary>
|
||||
public ITokenRateLimiter? RateLimiter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the token blacklist for revocation management.
|
||||
/// </summary>
|
||||
public ITokenBlacklist? Blacklist { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the token cache for validated token tracking.
|
||||
/// </summary>
|
||||
public ISecurityTokenCache? Cache { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets rate limit policy (used only if RateLimiter is not set).
|
||||
/// </summary>
|
||||
public RateLimitPolicy? RateLimitPolicy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to enable automatic cleanup of expired entries.
|
||||
/// </summary>
|
||||
public bool EnableAutomaticCleanup { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for SecureToken configuration with sensible defaults.
|
||||
/// Simplifies setup of token service with security and caching features.
|
||||
/// </summary>
|
||||
public sealed class SecureTokenConfigurationBuilder
|
||||
{
|
||||
private ITokenRateLimiter? _rateLimiter;
|
||||
private ITokenBlacklist? _blacklist;
|
||||
private ISecurityTokenCache? _cache;
|
||||
private RateLimitPolicy? _rateLimitPolicy;
|
||||
private bool _enableAutoCleanup = true;
|
||||
|
||||
/// <summary>
|
||||
/// Configures a rate limiter with default policy (5 attempts in 5 minutes, 15-minute lockout).
|
||||
/// </summary>
|
||||
public SecureTokenConfigurationBuilder WithRateLimiter()
|
||||
{
|
||||
_rateLimiter = new InMemoryTokenRateLimiter();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a rate limiter with a custom policy.
|
||||
/// </summary>
|
||||
/// <param name="policy">The rate limiting policy</param>
|
||||
public SecureTokenConfigurationBuilder WithRateLimiter(RateLimitPolicy policy)
|
||||
{
|
||||
if (policy is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(policy));
|
||||
}
|
||||
|
||||
_rateLimiter = new InMemoryTokenRateLimiter(policy);
|
||||
_rateLimitPolicy = policy;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a custom rate limiter implementation.
|
||||
/// </summary>
|
||||
/// <param name="rateLimiter">The rate limiter implementation</param>
|
||||
public SecureTokenConfigurationBuilder WithRateLimiter(ITokenRateLimiter rateLimiter)
|
||||
{
|
||||
_rateLimiter = rateLimiter ?? throw new ArgumentNullException(nameof(rateLimiter));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a token blacklist for revocation management.
|
||||
/// </summary>
|
||||
public SecureTokenConfigurationBuilder WithBlacklist()
|
||||
{
|
||||
_blacklist = new InMemoryTokenBlacklist();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a custom blacklist implementation.
|
||||
/// </summary>
|
||||
/// <param name="blacklist">The blacklist implementation</param>
|
||||
public SecureTokenConfigurationBuilder WithBlacklist(ITokenBlacklist blacklist)
|
||||
{
|
||||
_blacklist = blacklist ?? throw new ArgumentNullException(nameof(blacklist));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a token cache for tracking validated tokens.
|
||||
/// </summary>
|
||||
public SecureTokenConfigurationBuilder WithCache()
|
||||
{
|
||||
_cache = new InMemorySecurityTokenCache();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a custom cache implementation.
|
||||
/// </summary>
|
||||
/// <param name="cache">The cache implementation</param>
|
||||
public SecureTokenConfigurationBuilder WithCache(ISecurityTokenCache cache)
|
||||
{
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables automatic cleanup of expired entries. Cleanup will need to be done manually.
|
||||
/// </summary>
|
||||
public SecureTokenConfigurationBuilder DisableAutomaticCleanup()
|
||||
{
|
||||
_enableAutoCleanup = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures all security features with defaults (rate limiter, blacklist, cache).
|
||||
/// Recommended for most applications.
|
||||
/// </summary>
|
||||
public SecureTokenConfigurationBuilder WithAllSecurityFeatures()
|
||||
{
|
||||
WithRateLimiter();
|
||||
WithBlacklist();
|
||||
WithCache();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures minimal security features (rate limiter and blacklist, no cache).
|
||||
/// Suitable for high-performance scenarios where caching is not needed.
|
||||
/// </summary>
|
||||
public SecureTokenConfigurationBuilder WithMinimalSecurity()
|
||||
{
|
||||
WithRateLimiter();
|
||||
WithBlacklist();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the builder to initial state.
|
||||
/// </summary>
|
||||
public SecureTokenConfigurationBuilder Reset()
|
||||
{
|
||||
_rateLimiter = null;
|
||||
_blacklist = null;
|
||||
_cache = null;
|
||||
_rateLimitPolicy = null;
|
||||
_enableAutoCleanup = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the configuration.
|
||||
/// </summary>
|
||||
public SecureTokenConfiguration Build()
|
||||
{
|
||||
return new SecureTokenConfiguration
|
||||
{
|
||||
RateLimiter = _rateLimiter,
|
||||
Blacklist = _blacklist,
|
||||
Cache = _cache,
|
||||
RateLimitPolicy = _rateLimitPolicy,
|
||||
EnableAutomaticCleanup = _enableAutoCleanup
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper for quick configuration of the most common SecureToken setup scenarios.
|
||||
/// </summary>
|
||||
public static class SecureTokenPresets
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration for maximum security with all features enabled.
|
||||
/// Suitable for high-security applications where performance is not a primary concern.
|
||||
/// </summary>
|
||||
public static SecureTokenConfiguration MaximumSecurity()
|
||||
{
|
||||
return new SecureTokenConfigurationBuilder()
|
||||
.WithAllSecurityFeatures()
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for balanced security and performance.
|
||||
/// Recommended for most production applications.
|
||||
/// </summary>
|
||||
public static SecureTokenConfiguration Balanced()
|
||||
{
|
||||
return new SecureTokenConfigurationBuilder()
|
||||
.WithRateLimiter()
|
||||
.WithBlacklist()
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for high-performance scenarios with basic security.
|
||||
/// Only enables rate limiting; no caching or blacklist overhead.
|
||||
/// </summary>
|
||||
public static SecureTokenConfiguration HighPerformance()
|
||||
{
|
||||
return new SecureTokenConfigurationBuilder()
|
||||
.WithRateLimiter()
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration with no security features enabled.
|
||||
/// Only recommended for testing or internal environments.
|
||||
/// </summary>
|
||||
public static SecureTokenConfiguration NoSecurity()
|
||||
{
|
||||
return new SecureTokenConfiguration();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace EonaCat.SecureToken.Security
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
/// <summary>
|
||||
/// Provides token blacklisting/revocation functionality.
|
||||
/// Supports immediate revocation of tokens before their natural expiration.
|
||||
/// </summary>
|
||||
public interface ITokenBlacklist
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a token to the blacklist, immediately revoking it.
|
||||
/// </summary>
|
||||
/// <param name="tokenId">The token identifier to blacklist</param>
|
||||
/// <param name="reason">Optional reason for blacklisting</param>
|
||||
/// <param name="expiresAt">When the blacklist entry expires (usually token expiration time)</param>
|
||||
Task BlacklistAsync(string tokenId, string? reason = null, DateTimeOffset? expiresAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a token is blacklisted.
|
||||
/// </summary>
|
||||
/// <param name="tokenId">The token identifier</param>
|
||||
/// <returns>True if the token is blacklisted and not yet expired; false otherwise</returns>
|
||||
Task<bool> IsBlacklistedAsync(string tokenId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason why a token was blacklisted, if available.
|
||||
/// </summary>
|
||||
/// <param name="tokenId">The token identifier</param>
|
||||
/// <returns>The blacklist reason, or null if not blacklisted or no reason provided</returns>
|
||||
Task<string?> GetBlacklistReasonAsync(string tokenId);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a token from the blacklist (e.g., after review).
|
||||
/// </summary>
|
||||
/// <param name="tokenId">The token identifier</param>
|
||||
Task RemoveFromBlacklistAsync(string tokenId);
|
||||
|
||||
/// <summary>
|
||||
/// Removes all expired blacklist entries. Returns count of entries removed.
|
||||
/// </summary>
|
||||
Task<int> RemoveExpiredAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total count of currently blacklisted tokens.
|
||||
/// </summary>
|
||||
Task<int> CountAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Clears the entire blacklist. Returns count of entries removed.
|
||||
/// </summary>
|
||||
Task<int> ClearAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe in-memory implementation of <see cref="ITokenBlacklist"/>.
|
||||
/// Tracks revoked tokens with optional expiration cleanup.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTokenBlacklist : ITokenBlacklist
|
||||
{
|
||||
private sealed class BlacklistEntry
|
||||
{
|
||||
public DateTimeOffset BlacklistedAt { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
private readonly ConcurrentDictionary<string, BlacklistEntry> _blacklist =
|
||||
new ConcurrentDictionary<string, BlacklistEntry>();
|
||||
|
||||
private readonly object _cleanupLock = new object();
|
||||
private DateTimeOffset _lastCleanup = DateTimeOffset.UtcNow;
|
||||
private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Default expiration duration for blacklist entries if not specified.
|
||||
/// Set to 7 days to allow time for cache updates in distributed systems.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan DefaultBlacklistExpiration = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="InMemoryTokenBlacklist"/>.
|
||||
/// </summary>
|
||||
public InMemoryTokenBlacklist()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task BlacklistAsync(string tokenId, string? reason = null, DateTimeOffset? expiresAt = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
throw new ArgumentException("Token ID cannot be null or empty.", nameof(tokenId));
|
||||
}
|
||||
|
||||
var entry = new BlacklistEntry
|
||||
{
|
||||
BlacklistedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.Add(DefaultBlacklistExpiration),
|
||||
Reason = reason
|
||||
};
|
||||
|
||||
_blacklist.AddOrUpdate(tokenId, entry, (_, _) => entry);
|
||||
TriggerCleanupIfNeeded();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> IsBlacklistedAsync(string tokenId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (_blacklist.TryGetValue(tokenId, out var entry))
|
||||
{
|
||||
// Check if the blacklist entry has expired
|
||||
if (entry.ExpiresAt.HasValue && DateTimeOffset.UtcNow > entry.ExpiresAt)
|
||||
{
|
||||
// Remove the expired entry
|
||||
_blacklist.TryRemove(tokenId, out _);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetBlacklistReasonAsync(string tokenId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
if (_blacklist.TryGetValue(tokenId, out var entry))
|
||||
{
|
||||
// Check if the blacklist entry has expired
|
||||
if (entry.ExpiresAt.HasValue && DateTimeOffset.UtcNow > entry.ExpiresAt)
|
||||
{
|
||||
_blacklist.TryRemove(tokenId, out _);
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult(entry.Reason);
|
||||
}
|
||||
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveFromBlacklistAsync(string tokenId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
_blacklist.TryRemove(tokenId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> RemoveExpiredAsync()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expiredEntries = _blacklist
|
||||
.Where(kvp => kvp.Value.ExpiresAt.HasValue && now > kvp.Value.ExpiresAt)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
int removed = 0;
|
||||
foreach (var tokenId in expiredEntries)
|
||||
{
|
||||
if (_blacklist.TryRemove(tokenId, out _))
|
||||
{
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountAsync()
|
||||
{
|
||||
return Task.FromResult(_blacklist.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> ClearAsync()
|
||||
{
|
||||
var count = _blacklist.Count;
|
||||
_blacklist.Clear();
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
|
||||
private void TriggerCleanupIfNeeded()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (now - _lastCleanup >= _cleanupInterval)
|
||||
{
|
||||
lock (_cleanupLock)
|
||||
{
|
||||
if (now - _lastCleanup >= _cleanupInterval)
|
||||
{
|
||||
_lastCleanup = now;
|
||||
_ = RemoveExpiredAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace EonaCat.SecureToken.Security
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
/// <summary>
|
||||
/// Represents a rate limiting policy for token validation attempts.
|
||||
/// </summary>
|
||||
public sealed class RateLimitPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of failed attempts allowed within the time window.
|
||||
/// </summary>
|
||||
public int MaxFailedAttempts { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Time window during which failed attempts are counted.
|
||||
/// </summary>
|
||||
public TimeSpan TimeWindow { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Duration to block the subject after exceeding the limit.
|
||||
/// </summary>
|
||||
public TimeSpan LockoutDuration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks failed validation attempts and enforces rate limiting.
|
||||
/// Prevents brute-force attacks by throttling invalid token attempts.
|
||||
/// </summary>
|
||||
public interface ITokenRateLimiter
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a failed validation attempt for a subject (e.g., user ID, IP address).
|
||||
/// </summary>
|
||||
/// <param name="subject">The subject identifier (user, IP, etc.)</param>
|
||||
/// <returns>True if the attempt was recorded; false if subject is locked out</returns>
|
||||
Task<bool> TryRecordFailureAsync(string subject);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a subject is currently locked out due to too many failures.
|
||||
/// </summary>
|
||||
/// <param name="subject">The subject identifier</param>
|
||||
/// <returns>True if locked out; false otherwise</returns>
|
||||
Task<bool> IsLockedOutAsync(string subject);
|
||||
|
||||
/// <summary>
|
||||
/// Resets the failure count for a subject (e.g., after successful authentication).
|
||||
/// </summary>
|
||||
/// <param name="subject">The subject identifier</param>
|
||||
Task ResetAsync(string subject);
|
||||
|
||||
/// <summary>
|
||||
/// Manually unlocks a subject regardless of lockout status.
|
||||
/// </summary>
|
||||
/// <param name="subject">The subject identifier</param>
|
||||
Task UnlockAsync(string subject);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current failure count for a subject.
|
||||
/// </summary>
|
||||
/// <param name="subject">The subject identifier</param>
|
||||
/// <returns>The number of failures within the current time window</returns>
|
||||
Task<int> GetFailureCountAsync(string subject);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time remaining until a locked-out subject is unlocked.
|
||||
/// </summary>
|
||||
/// <param name="subject">The subject identifier</param>
|
||||
/// <returns>Time remaining; null if not locked out</returns>
|
||||
Task<TimeSpan?> GetLockoutTimeRemainingAsync(string subject);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all rate limit state. Returns count of subjects cleared.
|
||||
/// </summary>
|
||||
Task<int> ClearAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe in-memory implementation of <see cref="ITokenRateLimiter"/>.
|
||||
/// Tracks failed validation attempts per subject and enforces configurable rate limiting.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTokenRateLimiter : ITokenRateLimiter
|
||||
{
|
||||
private sealed class FailureTracker
|
||||
{
|
||||
public List<DateTimeOffset> Failures { get; } = new List<DateTimeOffset>();
|
||||
public DateTimeOffset? LockedUntil { get; set; }
|
||||
}
|
||||
|
||||
private readonly ConcurrentDictionary<string, FailureTracker> _trackers =
|
||||
new ConcurrentDictionary<string, FailureTracker>();
|
||||
|
||||
private readonly RateLimitPolicy _policy;
|
||||
private readonly object _cleanupLock = new object();
|
||||
private DateTimeOffset _lastCleanup = DateTimeOffset.UtcNow;
|
||||
private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="InMemoryTokenRateLimiter"/>.
|
||||
/// </summary>
|
||||
/// <param name="policy">The rate limiting policy; uses defaults if null</param>
|
||||
public InMemoryTokenRateLimiter(RateLimitPolicy? policy = null)
|
||||
{
|
||||
_policy = policy ?? new RateLimitPolicy();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TryRecordFailureAsync(string subject)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var tracker = _trackers.GetOrAdd(subject, _ => new FailureTracker());
|
||||
|
||||
// Check if subject is locked out
|
||||
if (tracker.LockedUntil.HasValue && now < tracker.LockedUntil)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Reset lockout if time has passed
|
||||
if (tracker.LockedUntil.HasValue && now >= tracker.LockedUntil)
|
||||
{
|
||||
tracker.LockedUntil = null;
|
||||
tracker.Failures.Clear();
|
||||
}
|
||||
|
||||
// Remove failures outside the time window
|
||||
tracker.Failures.RemoveAll(f => now - f > _policy.TimeWindow);
|
||||
|
||||
// Record the new failure
|
||||
tracker.Failures.Add(now);
|
||||
|
||||
// Check if limit exceeded
|
||||
if (tracker.Failures.Count > _policy.MaxFailedAttempts)
|
||||
{
|
||||
tracker.LockedUntil = now.Add(_policy.LockoutDuration);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
TriggerCleanupIfNeeded();
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> IsLockedOutAsync(string subject)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (_trackers.TryGetValue(subject, out var tracker))
|
||||
{
|
||||
if (tracker.LockedUntil.HasValue && DateTimeOffset.UtcNow < tracker.LockedUntil)
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
// Reset if lockout has expired
|
||||
if (tracker.LockedUntil.HasValue && DateTimeOffset.UtcNow >= tracker.LockedUntil)
|
||||
{
|
||||
tracker.LockedUntil = null;
|
||||
tracker.Failures.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ResetAsync(string subject)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(subject) && _trackers.TryGetValue(subject, out var tracker))
|
||||
{
|
||||
tracker.Failures.Clear();
|
||||
tracker.LockedUntil = null;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnlockAsync(string subject)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(subject) && _trackers.TryGetValue(subject, out var tracker))
|
||||
{
|
||||
tracker.LockedUntil = null;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> GetFailureCountAsync(string subject)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subject) || !_trackers.TryGetValue(subject, out var tracker))
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var recentFailures = tracker.Failures.Count(f => now - f <= _policy.TimeWindow);
|
||||
return Task.FromResult(recentFailures);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TimeSpan?> GetLockoutTimeRemainingAsync(string subject)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subject) || !_trackers.TryGetValue(subject, out var tracker))
|
||||
{
|
||||
return Task.FromResult<TimeSpan?>(null);
|
||||
}
|
||||
|
||||
if (!tracker.LockedUntil.HasValue || DateTimeOffset.UtcNow >= tracker.LockedUntil)
|
||||
{
|
||||
return Task.FromResult<TimeSpan?>(null);
|
||||
}
|
||||
|
||||
var remaining = tracker.LockedUntil.Value - DateTimeOffset.UtcNow;
|
||||
return Task.FromResult<TimeSpan?>(remaining > TimeSpan.Zero ? remaining : null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> ClearAsync()
|
||||
{
|
||||
var count = _trackers.Count;
|
||||
_trackers.Clear();
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
|
||||
private void TriggerCleanupIfNeeded()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (now - _lastCleanup >= _cleanupInterval)
|
||||
{
|
||||
lock (_cleanupLock)
|
||||
{
|
||||
if (now - _lastCleanup >= _cleanupInterval)
|
||||
{
|
||||
_lastCleanup = now;
|
||||
// Remove subjects with no recent failures and unlocked status
|
||||
var toRemove = _trackers
|
||||
.Where(kvp =>
|
||||
kvp.Value.Failures.Count == 0 &&
|
||||
(!kvp.Value.LockedUntil.HasValue || now > kvp.Value.LockedUntil.Value))
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
_trackers.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user