From 6482f0c94c2513db1d0ad8ba6c0e1b21168c6424 Mon Sep 17 00:00:00 2001 From: EonaCat Date: Mon, 22 Jun 2026 07:39:20 +0200 Subject: [PATCH] Added extra token security and token rateLimiting --- EonaCat.SecureToken/Core/TokenIntrospector.cs | 239 +++++++++++ .../EonaCat.SecureToken.csproj | 6 +- .../Extensions/ServiceCollectionExtensions.cs | 73 ++++ .../Extensions/TokenServiceExtensions.cs | 390 ++++++++++++++++++ .../Models/SecurityTokenCache.cs | 174 ++++++++ .../Models/TokenPairDescriptor.cs | 185 +++++++++ .../SecureTokenConfigurationBuilder.cs | 234 +++++++++++ .../Security/TokenBlacklist.cs | 224 ++++++++++ .../Security/TokenRateLimiter.cs | 269 ++++++++++++ 9 files changed, 1791 insertions(+), 3 deletions(-) create mode 100644 EonaCat.SecureToken/Core/TokenIntrospector.cs create mode 100644 EonaCat.SecureToken/Extensions/TokenServiceExtensions.cs create mode 100644 EonaCat.SecureToken/Models/SecurityTokenCache.cs create mode 100644 EonaCat.SecureToken/Models/TokenPairDescriptor.cs create mode 100644 EonaCat.SecureToken/SecureTokenConfigurationBuilder.cs create mode 100644 EonaCat.SecureToken/Security/TokenBlacklist.cs create mode 100644 EonaCat.SecureToken/Security/TokenRateLimiter.cs diff --git a/EonaCat.SecureToken/Core/TokenIntrospector.cs b/EonaCat.SecureToken/Core/TokenIntrospector.cs new file mode 100644 index 0000000..1abc947 --- /dev/null +++ b/EonaCat.SecureToken/Core/TokenIntrospector.cs @@ -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. + + /// + /// Detailed information about a token, extracted without signature verification. + /// Use only for diagnostic and audit purposes, never for authorization. + /// + public sealed class TokenIntrospectionResult + { + /// + /// Whether the token format is valid (can be parsed). + /// + public bool IsValidFormat { get; set; } + + /// + /// The token claims if the format is valid; otherwise null. + /// + public TokenClaims? Claims { get; set; } + + /// + /// Error message if parsing failed. + /// + public string? ErrorMessage { get; set; } + + /// + /// Additional diagnostic information. + /// + public Dictionary Diagnostics { get; set; } = new Dictionary(); + + /// + /// Creates a successful introspection result. + /// + public static TokenIntrospectionResult Success(TokenClaims claims) + { + return new TokenIntrospectionResult + { + IsValidFormat = true, + Claims = claims, + ErrorMessage = null + }; + } + + /// + /// Creates a failure introspection result. + /// + public static TokenIntrospectionResult Failure(string errorMessage) + { + return new TokenIntrospectionResult + { + IsValidFormat = false, + Claims = null, + ErrorMessage = errorMessage + }; + } + } + + /// + /// Provides detailed token introspection for diagnostic and audit purposes. + /// This is useful for debugging token issues and understanding token structure. + /// + public interface ITokenIntrospector + { + /// + /// Analyzes a token without verifying the signature. + /// Returns detailed information about the token structure and claims. + /// Use only for diagnostics - never for authorization decisions. + /// + /// The token to introspect + /// Introspection result with claims or error information + TokenIntrospectionResult Introspect(string token); + + /// + /// Analyzes multiple tokens and returns their information. + /// + /// Tokens to introspect + /// Introspection results for each token + IReadOnlyList IntrospectMultiple(params string[] tokens); + + /// + /// Extracts and analyzes the raw token parts (header, payload, signature) for debugging. + /// + /// The token to analyze + /// The Base64-encoded payload if parsing succeeds + /// The Base64-encoded signature if parsing succeeds + /// True if the token format is valid; false otherwise + bool TryGetRawParts(string token, out string? encodedPayload, out string? encodedSignature); + + /// + /// Gets a human-readable summary of token claims for debugging. + /// + /// The token to summarize + /// Formatted summary string + string GetClaimsSummary(string token); + } + + /// + /// Default implementation of token introspection for diagnostics. + /// + public sealed class TokenIntrospector : ITokenIntrospector + { + private readonly ITokenService _tokenService; + + /// + /// Initializes a new instance of . + /// + /// The token service to use for introspection + public TokenIntrospector(ITokenService tokenService) + { + _tokenService = tokenService ?? throw new ArgumentNullException(nameof(tokenService)); + } + + /// + 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; + } + + /// + public IReadOnlyList IntrospectMultiple(params string[] tokens) + { + if (tokens is null) + { + throw new ArgumentNullException(nameof(tokens)); + } + + var results = new List(tokens.Length); + foreach (var token in tokens) + { + results.Add(Introspect(token)); + } + + return results.AsReadOnly(); + } + + /// + 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; + } + + /// + 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(); + } + } +} diff --git a/EonaCat.SecureToken/EonaCat.SecureToken.csproj b/EonaCat.SecureToken/EonaCat.SecureToken.csproj index 17b4a1f..e00ea35 100644 --- a/EonaCat.SecureToken/EonaCat.SecureToken.csproj +++ b/EonaCat.SecureToken/EonaCat.SecureToken.csproj @@ -14,7 +14,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v EonaCat (Jeroen Saey) EonaCat;authentication;api;micro;services;microservices;rotation;secure;token;validation;jwt;security;secure;token;paseto;security;auth;jwt-alternative;Jeroen;Saey - 0.0.3 + 0.0.4 README.md True LICENSE @@ -25,7 +25,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v - 0.0.3+{chash:10}.{c:ymd} + 0.0.4+{chash:10}.{c:ymd} true true v[0-9]* @@ -36,7 +36,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v - 0.0.3 + 0.0.4 EonaCat.SecureToken EonaCat.SecureToken https://git.saey.me/EonaCat/EonaCat.SecureToken diff --git a/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs b/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs index 6dc1c4c..04aa907 100644 --- a/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs +++ b/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs @@ -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 /// /// Extension methods for registering SecureToken services with the DI container. + /// Includes support for token service, introspection, rate limiting, blacklisting, and caching. /// public static class ServiceCollectionExtensions { @@ -65,5 +68,75 @@ namespace EonaCat.SecureToken.Extensions services.AddSingleton(); return services; } + + /// + /// Adds token introspection service for diagnostic and audit purposes. + /// Allows detailed examination of token claims without signature verification. + /// + public static IServiceCollection AddSecureTokenIntrospection(this IServiceCollection services) + { + services.AddSingleton(sp => + new TokenIntrospector(sp.GetRequiredService())); + return services; + } + + /// + /// Adds token rate limiting for failed validation attempt throttling. + /// Protects against brute-force attacks on token validation. + /// + /// The service collection + /// Optional rate limiting policy; uses defaults if null + public static IServiceCollection AddSecureTokenRateLimiting( + this IServiceCollection services, + RateLimitPolicy? policy = null) + { + services.AddSingleton( + _ => new InMemoryTokenRateLimiter(policy)); + return services; + } + + /// + /// Adds token blacklist for revocation management. + /// Supports immediate revocation of tokens before their natural expiration. + /// + public static IServiceCollection AddSecureTokenBlacklist(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } + + /// + /// Adds token caching for reducing validation computational overhead. + /// Validates tokens once and caches the result for frequent lookups. + /// + public static IServiceCollection AddSecureTokenCache(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } + + /// + /// Adds all security features (introspection, rate limiting, blacklist, and cache). + /// Recommended for most production applications. + /// + public static IServiceCollection AddSecureTokenWithAllFeatures(this IServiceCollection services) + { + return services + .AddSecureTokenIntrospection() + .AddSecureTokenRateLimiting() + .AddSecureTokenBlacklist() + .AddSecureTokenCache(); + } + + /// + /// Adds a subset of security features (rate limiting and blacklist only). + /// Suitable for high-performance scenarios where caching is not needed. + /// + public static IServiceCollection AddSecureTokenWithMinimalSecurity(this IServiceCollection services) + { + return services + .AddSecureTokenRateLimiting() + .AddSecureTokenBlacklist(); + } } } diff --git a/EonaCat.SecureToken/Extensions/TokenServiceExtensions.cs b/EonaCat.SecureToken/Extensions/TokenServiceExtensions.cs new file mode 100644 index 0000000..31c66cd --- /dev/null +++ b/EonaCat.SecureToken/Extensions/TokenServiceExtensions.cs @@ -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. + + /// + /// Extension methods for advanced token operations including batch processing, + /// security checks, and validation with additional features. + /// + public static class TokenServiceExtensions + { + /// + /// Validates multiple tokens concurrently. + /// + /// The token service + /// Tokens to validate + /// Validation options + /// Cancellation token + /// Collection of validation results in the same order as input tokens + public static async Task> ValidateManyAsync( + this ITokenService service, + IEnumerable 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(tokenList.Count); + + foreach (var token in tokenList) + { + results.Add(await service.ValidateAsync(token, options, ct).ConfigureAwait(false)); + } + + return results.AsReadOnly(); + } + + /// + /// Validates a token and checks the blacklist. + /// + /// The token service + /// Token to validate + /// Validation options + /// The blacklist to check + /// Cancellation token + /// Validation result (or Revoked if token is blacklisted) + public static async Task 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; + } + + /// + /// Validates a token with rate limiting on the subject. + /// + /// The token service + /// Token to validate + /// Validation options + /// The rate limiter + /// Subject to rate limit (e.g., user ID, IP address) + /// Cancellation token + /// Validation result (or failure if rate limited) + public static async Task 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; + } + + /// + /// Validates a token and caches the result for subsequent lookups. + /// Reduces computational overhead for frequently-validated tokens. + /// + /// The token service + /// Token to validate + /// Validation options + /// The cache to store validated tokens + /// Cancellation token + /// Validation result + public static async Task 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; + } + + /// + /// Validates a token with all available security checks (rate limiting, blacklist, caching). + /// + /// The token service + /// Token to validate + /// Validation options + /// Security configuration with rate limiter, blacklist, and/or cache + /// Optional subject for rate limiting (IP, user ID, etc.) + /// Cancellation token + /// Validation result with all security checks applied + public static async Task 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; + } + + /// + /// Gets a summary of all failed validations for a subject during the current rate limit window. + /// Useful for security auditing and debugging. + /// + /// The rate limiter + /// The subject to check + /// Summary information including failure count and lockout status + public static async Task 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)."; + } + + /// + /// Introspects and compares multiple tokens for diagnostic purposes. + /// + /// The token introspector + /// Tokens to compare + /// Formatted comparison of token claims + 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(); + } + } +} diff --git a/EonaCat.SecureToken/Models/SecurityTokenCache.cs b/EonaCat.SecureToken/Models/SecurityTokenCache.cs new file mode 100644 index 0000000..f866c22 --- /dev/null +++ b/EonaCat.SecureToken/Models/SecurityTokenCache.cs @@ -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. + + /// + /// Provides in-memory caching and lifecycle management for validated tokens. + /// This interface allows for extensible cache implementations (in-memory, distributed, database, etc.). + /// + public interface ISecurityTokenCache + { + /// + /// Records a validated token in the cache. + /// + /// The token identifier + /// When the token expires + Task SetAsync(string tokenId, DateTimeOffset expiresAt); + + /// + /// Retrieves a token from the cache if it exists and has not expired. + /// + /// The token identifier + /// True if the token is in cache and valid; otherwise false + Task TryGetAsync(string tokenId); + + /// + /// Removes a token from the cache (e.g., after revocation). + /// + /// The token identifier + Task RemoveAsync(string tokenId); + + /// + /// Clears all tokens from the cache. Returns the count of items removed. + /// + Task ClearAsync(); + + /// + /// Gets the current count of cached tokens. + /// + Task CountAsync(); + + /// + /// Removes all expired tokens from the cache. Returns the count of items removed. + /// + Task RemoveExpiredAsync(); + } + + /// + /// Thread-safe in-memory implementation of . + /// Automatically removes expired tokens during operations and periodically. + /// Suitable for single-server deployments; for distributed systems, use a distributed cache. + /// + public sealed class InMemorySecurityTokenCache : ISecurityTokenCache + { + private readonly ConcurrentDictionary _cache = + new ConcurrentDictionary(); + + private readonly object _cleanupLock = new object(); + private DateTimeOffset _lastCleanup = DateTimeOffset.UtcNow; + private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(5); + + /// + /// Initializes a new instance of . + /// + public InMemorySecurityTokenCache() + { + } + + /// + 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; + } + + /// + public Task 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); + } + + /// + public Task RemoveAsync(string tokenId) + { + if (!string.IsNullOrWhiteSpace(tokenId)) + { + _cache.TryRemove(tokenId, out _); + } + + return Task.CompletedTask; + } + + /// + public Task ClearAsync() + { + var count = _cache.Count; + _cache.Clear(); + return Task.FromResult(count); + } + + /// + public Task CountAsync() + { + return Task.FromResult(_cache.Count); + } + + /// + public Task 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(); + } + } + } + } + } +} diff --git a/EonaCat.SecureToken/Models/TokenPairDescriptor.cs b/EonaCat.SecureToken/Models/TokenPairDescriptor.cs new file mode 100644 index 0000000..54c97fe --- /dev/null +++ b/EonaCat.SecureToken/Models/TokenPairDescriptor.cs @@ -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. + + /// + /// Represents an access token and refresh token pair. + /// + public sealed class AccessTokenRefreshTokenPair + { + /// + /// The access token (short-lived, typically for API requests). + /// + public string AccessToken { get; set; } + + /// + /// The refresh token (long-lived, used to obtain new access tokens). + /// + public string RefreshToken { get; set; } + + /// + /// Creates a new token pair. + /// + 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; + } + } + + /// + /// Helper for issuing paired access and refresh tokens with optimal defaults. + /// + public static class TokenPairDescriptor + { + /// + /// Default lifetime for access tokens: 15 minutes. + /// + public static readonly TimeSpan DefaultAccessTokenLifetime = TimeSpan.FromMinutes(15); + + /// + /// Default lifetime for refresh tokens: 30 days. + /// + public static readonly TimeSpan DefaultRefreshTokenLifetime = TimeSpan.FromDays(30); + + /// + /// Creates an access token and refresh token pair with secure defaults. + /// + /// The token service + /// The subject (user ID, etc.) + /// The issuer + /// The audience + /// Custom access token lifetime; defaults to 15 minutes + /// Custom refresh token lifetime; defaults to 30 days + /// A pair of access and refresh tokens + 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); + } + + /// + /// Creates an access token and refresh token pair with custom claims and roles. + /// + /// The token service + /// The subject (user ID, etc.) + /// The issuer + /// The audience + /// Optional action to configure additional claims and roles + /// Custom access token lifetime; defaults to 15 minutes + /// Custom refresh token lifetime; defaults to 30 days + /// A pair of access and refresh tokens + public static AccessTokenRefreshTokenPair CreatePair( + ITokenService service, + string subject, + string issuer, + string audience, + Action? 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); + } + } +} diff --git a/EonaCat.SecureToken/SecureTokenConfigurationBuilder.cs b/EonaCat.SecureToken/SecureTokenConfigurationBuilder.cs new file mode 100644 index 0000000..5989b4b --- /dev/null +++ b/EonaCat.SecureToken/SecureTokenConfigurationBuilder.cs @@ -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. + + /// + /// Configuration for SecureToken with security and caching features. + /// + public sealed class SecureTokenConfiguration + { + /// + /// Gets or sets the token rate limiter for failed validation attempt throttling. + /// + public ITokenRateLimiter? RateLimiter { get; set; } + + /// + /// Gets or sets the token blacklist for revocation management. + /// + public ITokenBlacklist? Blacklist { get; set; } + + /// + /// Gets or sets the token cache for validated token tracking. + /// + public ISecurityTokenCache? Cache { get; set; } + + /// + /// Gets or sets rate limit policy (used only if RateLimiter is not set). + /// + public RateLimitPolicy? RateLimitPolicy { get; set; } + + /// + /// Gets or sets whether to enable automatic cleanup of expired entries. + /// + public bool EnableAutomaticCleanup { get; set; } = true; + } + + /// + /// Fluent builder for SecureToken configuration with sensible defaults. + /// Simplifies setup of token service with security and caching features. + /// + public sealed class SecureTokenConfigurationBuilder + { + private ITokenRateLimiter? _rateLimiter; + private ITokenBlacklist? _blacklist; + private ISecurityTokenCache? _cache; + private RateLimitPolicy? _rateLimitPolicy; + private bool _enableAutoCleanup = true; + + /// + /// Configures a rate limiter with default policy (5 attempts in 5 minutes, 15-minute lockout). + /// + public SecureTokenConfigurationBuilder WithRateLimiter() + { + _rateLimiter = new InMemoryTokenRateLimiter(); + return this; + } + + /// + /// Configures a rate limiter with a custom policy. + /// + /// The rate limiting policy + public SecureTokenConfigurationBuilder WithRateLimiter(RateLimitPolicy policy) + { + if (policy is null) + { + throw new ArgumentNullException(nameof(policy)); + } + + _rateLimiter = new InMemoryTokenRateLimiter(policy); + _rateLimitPolicy = policy; + return this; + } + + /// + /// Configures a custom rate limiter implementation. + /// + /// The rate limiter implementation + public SecureTokenConfigurationBuilder WithRateLimiter(ITokenRateLimiter rateLimiter) + { + _rateLimiter = rateLimiter ?? throw new ArgumentNullException(nameof(rateLimiter)); + return this; + } + + /// + /// Configures a token blacklist for revocation management. + /// + public SecureTokenConfigurationBuilder WithBlacklist() + { + _blacklist = new InMemoryTokenBlacklist(); + return this; + } + + /// + /// Configures a custom blacklist implementation. + /// + /// The blacklist implementation + public SecureTokenConfigurationBuilder WithBlacklist(ITokenBlacklist blacklist) + { + _blacklist = blacklist ?? throw new ArgumentNullException(nameof(blacklist)); + return this; + } + + /// + /// Configures a token cache for tracking validated tokens. + /// + public SecureTokenConfigurationBuilder WithCache() + { + _cache = new InMemorySecurityTokenCache(); + return this; + } + + /// + /// Configures a custom cache implementation. + /// + /// The cache implementation + public SecureTokenConfigurationBuilder WithCache(ISecurityTokenCache cache) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + return this; + } + + /// + /// Disables automatic cleanup of expired entries. Cleanup will need to be done manually. + /// + public SecureTokenConfigurationBuilder DisableAutomaticCleanup() + { + _enableAutoCleanup = false; + return this; + } + + /// + /// Configures all security features with defaults (rate limiter, blacklist, cache). + /// Recommended for most applications. + /// + public SecureTokenConfigurationBuilder WithAllSecurityFeatures() + { + WithRateLimiter(); + WithBlacklist(); + WithCache(); + return this; + } + + /// + /// Configures minimal security features (rate limiter and blacklist, no cache). + /// Suitable for high-performance scenarios where caching is not needed. + /// + public SecureTokenConfigurationBuilder WithMinimalSecurity() + { + WithRateLimiter(); + WithBlacklist(); + return this; + } + + /// + /// Resets the builder to initial state. + /// + public SecureTokenConfigurationBuilder Reset() + { + _rateLimiter = null; + _blacklist = null; + _cache = null; + _rateLimitPolicy = null; + _enableAutoCleanup = true; + return this; + } + + /// + /// Builds the configuration. + /// + public SecureTokenConfiguration Build() + { + return new SecureTokenConfiguration + { + RateLimiter = _rateLimiter, + Blacklist = _blacklist, + Cache = _cache, + RateLimitPolicy = _rateLimitPolicy, + EnableAutomaticCleanup = _enableAutoCleanup + }; + } + } + + /// + /// Helper for quick configuration of the most common SecureToken setup scenarios. + /// + public static class SecureTokenPresets + { + /// + /// Configuration for maximum security with all features enabled. + /// Suitable for high-security applications where performance is not a primary concern. + /// + public static SecureTokenConfiguration MaximumSecurity() + { + return new SecureTokenConfigurationBuilder() + .WithAllSecurityFeatures() + .Build(); + } + + /// + /// Configuration for balanced security and performance. + /// Recommended for most production applications. + /// + public static SecureTokenConfiguration Balanced() + { + return new SecureTokenConfigurationBuilder() + .WithRateLimiter() + .WithBlacklist() + .Build(); + } + + /// + /// Configuration for high-performance scenarios with basic security. + /// Only enables rate limiting; no caching or blacklist overhead. + /// + public static SecureTokenConfiguration HighPerformance() + { + return new SecureTokenConfigurationBuilder() + .WithRateLimiter() + .Build(); + } + + /// + /// Configuration with no security features enabled. + /// Only recommended for testing or internal environments. + /// + public static SecureTokenConfiguration NoSecurity() + { + return new SecureTokenConfiguration(); + } + } +} diff --git a/EonaCat.SecureToken/Security/TokenBlacklist.cs b/EonaCat.SecureToken/Security/TokenBlacklist.cs new file mode 100644 index 0000000..7a7c053 --- /dev/null +++ b/EonaCat.SecureToken/Security/TokenBlacklist.cs @@ -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. + + /// + /// Provides token blacklisting/revocation functionality. + /// Supports immediate revocation of tokens before their natural expiration. + /// + public interface ITokenBlacklist + { + /// + /// Adds a token to the blacklist, immediately revoking it. + /// + /// The token identifier to blacklist + /// Optional reason for blacklisting + /// When the blacklist entry expires (usually token expiration time) + Task BlacklistAsync(string tokenId, string? reason = null, DateTimeOffset? expiresAt = null); + + /// + /// Checks if a token is blacklisted. + /// + /// The token identifier + /// True if the token is blacklisted and not yet expired; false otherwise + Task IsBlacklistedAsync(string tokenId); + + /// + /// Gets the reason why a token was blacklisted, if available. + /// + /// The token identifier + /// The blacklist reason, or null if not blacklisted or no reason provided + Task GetBlacklistReasonAsync(string tokenId); + + /// + /// Removes a token from the blacklist (e.g., after review). + /// + /// The token identifier + Task RemoveFromBlacklistAsync(string tokenId); + + /// + /// Removes all expired blacklist entries. Returns count of entries removed. + /// + Task RemoveExpiredAsync(); + + /// + /// Gets the total count of currently blacklisted tokens. + /// + Task CountAsync(); + + /// + /// Clears the entire blacklist. Returns count of entries removed. + /// + Task ClearAsync(); + } + + /// + /// Thread-safe in-memory implementation of . + /// Tracks revoked tokens with optional expiration cleanup. + /// + 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 _blacklist = + new ConcurrentDictionary(); + + private readonly object _cleanupLock = new object(); + private DateTimeOffset _lastCleanup = DateTimeOffset.UtcNow; + private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(5); + + /// + /// Default expiration duration for blacklist entries if not specified. + /// Set to 7 days to allow time for cache updates in distributed systems. + /// + private static readonly TimeSpan DefaultBlacklistExpiration = TimeSpan.FromDays(7); + + /// + /// Initializes a new instance of . + /// + public InMemoryTokenBlacklist() + { + } + + /// + 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; + } + + /// + public Task 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); + } + + /// + public Task GetBlacklistReasonAsync(string tokenId) + { + if (string.IsNullOrWhiteSpace(tokenId)) + { + return Task.FromResult(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(null); + } + + return Task.FromResult(entry.Reason); + } + + return Task.FromResult(null); + } + + /// + public Task RemoveFromBlacklistAsync(string tokenId) + { + if (!string.IsNullOrWhiteSpace(tokenId)) + { + _blacklist.TryRemove(tokenId, out _); + } + + return Task.CompletedTask; + } + + /// + public Task 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); + } + + /// + public Task CountAsync() + { + return Task.FromResult(_blacklist.Count); + } + + /// + public Task 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(); + } + } + } + } + } +} diff --git a/EonaCat.SecureToken/Security/TokenRateLimiter.cs b/EonaCat.SecureToken/Security/TokenRateLimiter.cs new file mode 100644 index 0000000..6a88b60 --- /dev/null +++ b/EonaCat.SecureToken/Security/TokenRateLimiter.cs @@ -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. + + /// + /// Represents a rate limiting policy for token validation attempts. + /// + public sealed class RateLimitPolicy + { + /// + /// Maximum number of failed attempts allowed within the time window. + /// + public int MaxFailedAttempts { get; set; } = 5; + + /// + /// Time window during which failed attempts are counted. + /// + public TimeSpan TimeWindow { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Duration to block the subject after exceeding the limit. + /// + public TimeSpan LockoutDuration { get; set; } = TimeSpan.FromMinutes(15); + } + + /// + /// Tracks failed validation attempts and enforces rate limiting. + /// Prevents brute-force attacks by throttling invalid token attempts. + /// + public interface ITokenRateLimiter + { + /// + /// Records a failed validation attempt for a subject (e.g., user ID, IP address). + /// + /// The subject identifier (user, IP, etc.) + /// True if the attempt was recorded; false if subject is locked out + Task TryRecordFailureAsync(string subject); + + /// + /// Checks if a subject is currently locked out due to too many failures. + /// + /// The subject identifier + /// True if locked out; false otherwise + Task IsLockedOutAsync(string subject); + + /// + /// Resets the failure count for a subject (e.g., after successful authentication). + /// + /// The subject identifier + Task ResetAsync(string subject); + + /// + /// Manually unlocks a subject regardless of lockout status. + /// + /// The subject identifier + Task UnlockAsync(string subject); + + /// + /// Gets the current failure count for a subject. + /// + /// The subject identifier + /// The number of failures within the current time window + Task GetFailureCountAsync(string subject); + + /// + /// Gets the time remaining until a locked-out subject is unlocked. + /// + /// The subject identifier + /// Time remaining; null if not locked out + Task GetLockoutTimeRemainingAsync(string subject); + + /// + /// Clears all rate limit state. Returns count of subjects cleared. + /// + Task ClearAsync(); + } + + /// + /// Thread-safe in-memory implementation of . + /// Tracks failed validation attempts per subject and enforces configurable rate limiting. + /// + public sealed class InMemoryTokenRateLimiter : ITokenRateLimiter + { + private sealed class FailureTracker + { + public List Failures { get; } = new List(); + public DateTimeOffset? LockedUntil { get; set; } + } + + private readonly ConcurrentDictionary _trackers = + new ConcurrentDictionary(); + + private readonly RateLimitPolicy _policy; + private readonly object _cleanupLock = new object(); + private DateTimeOffset _lastCleanup = DateTimeOffset.UtcNow; + private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(10); + + /// + /// Initializes a new instance of . + /// + /// The rate limiting policy; uses defaults if null + public InMemoryTokenRateLimiter(RateLimitPolicy? policy = null) + { + _policy = policy ?? new RateLimitPolicy(); + } + + /// + public Task 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); + } + + /// + public Task 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); + } + + /// + public Task ResetAsync(string subject) + { + if (!string.IsNullOrWhiteSpace(subject) && _trackers.TryGetValue(subject, out var tracker)) + { + tracker.Failures.Clear(); + tracker.LockedUntil = null; + } + + return Task.CompletedTask; + } + + /// + public Task UnlockAsync(string subject) + { + if (!string.IsNullOrWhiteSpace(subject) && _trackers.TryGetValue(subject, out var tracker)) + { + tracker.LockedUntil = null; + } + + return Task.CompletedTask; + } + + /// + public Task 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); + } + + /// + public Task GetLockoutTimeRemainingAsync(string subject) + { + if (string.IsNullOrWhiteSpace(subject) || !_trackers.TryGetValue(subject, out var tracker)) + { + return Task.FromResult(null); + } + + if (!tracker.LockedUntil.HasValue || DateTimeOffset.UtcNow >= tracker.LockedUntil) + { + return Task.FromResult(null); + } + + var remaining = tracker.LockedUntil.Value - DateTimeOffset.UtcNow; + return Task.FromResult(remaining > TimeSpan.Zero ? remaining : null); + } + + /// + public Task 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 _); + } + } + } + } + } + } +}