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>
|
<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>
|
<PackageTags>EonaCat;authentication;api;micro;services;microservices;rotation;secure;token;validation;jwt;security;secure;token;paseto;security;auth;jwt-alternative;Jeroen;Saey</PackageTags>
|
||||||
<PackageIconUrl />
|
<PackageIconUrl />
|
||||||
<FileVersion>0.0.3</FileVersion>
|
<FileVersion>0.0.4</FileVersion>
|
||||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
||||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||||
@@ -25,7 +25,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<EVRevisionFormat>0.0.3+{chash:10}.{c:ymd}</EVRevisionFormat>
|
<EVRevisionFormat>0.0.4+{chash:10}.{c:ymd}</EVRevisionFormat>
|
||||||
<EVDefault>true</EVDefault>
|
<EVDefault>true</EVDefault>
|
||||||
<EVInfo>true</EVInfo>
|
<EVInfo>true</EVInfo>
|
||||||
<EVTagMatch>v[0-9]*</EVTagMatch>
|
<EVTagMatch>v[0-9]*</EVTagMatch>
|
||||||
@@ -36,7 +36,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.0.3</Version>
|
<Version>0.0.4</Version>
|
||||||
<PackageId>EonaCat.SecureToken</PackageId>
|
<PackageId>EonaCat.SecureToken</PackageId>
|
||||||
<Product>EonaCat.SecureToken</Product>
|
<Product>EonaCat.SecureToken</Product>
|
||||||
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.SecureToken</RepositoryUrl>
|
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.SecureToken</RepositoryUrl>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using EonaCat.SecureToken.Cryptography;
|
using EonaCat.SecureToken.Cryptography;
|
||||||
using EonaCat.SecureToken.Validation;
|
using EonaCat.SecureToken.Validation;
|
||||||
|
using EonaCat.SecureToken.Core;
|
||||||
|
using EonaCat.SecureToken.Security;
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace EonaCat.SecureToken.Extensions
|
namespace EonaCat.SecureToken.Extensions
|
||||||
@@ -10,6 +12,7 @@ namespace EonaCat.SecureToken.Extensions
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extension methods for registering SecureToken services with the DI container.
|
/// Extension methods for registering SecureToken services with the DI container.
|
||||||
|
/// Includes support for token service, introspection, rate limiting, blacklisting, and caching.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ServiceCollectionExtensions
|
public static class ServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
@@ -65,5 +68,75 @@ namespace EonaCat.SecureToken.Extensions
|
|||||||
services.AddSingleton<IReplayCache, InMemoryReplayCache>();
|
services.AddSingleton<IReplayCache, InMemoryReplayCache>();
|
||||||
return services;
|
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