Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/Exceptionless.Core/Bootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
services.AddSingleton<ILockProvider>(s => s.GetRequiredService<CacheLockProvider>());
services.AddTransient<StripeEventHandler>();
services.AddSingleton<IStripeBillingClient, StripeBillingClient>();
services.AddSingleton<AuthService>();
services.AddSingleton<BillingManager>();
services.AddSingleton<BillingPlans>();
services.AddSingleton<EventPostService>();
Expand All @@ -204,6 +205,8 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AllowAutoRedirect = false,
UseCookies = false,
UseProxy = false,
ConnectCallback = ConnectToPublicAddressAsync
});
services.AddSingleton<SourceMapRequestThrottle>();
Expand Down Expand Up @@ -231,7 +234,7 @@ private static async ValueTask<Stream> ConnectToPublicAddressAsync(SocketsHttpCo
Exception? lastException = null;
foreach (var address in addresses)
{
if (!OAuthClientMetadataService.IsPublicAddress(address))
if (!PublicAddressPolicy.IsPublic(address))
continue;

var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Core/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public record User : IIdentity, IHaveDates, IValidatableObject
public DateTime VerifyEmailAddressTokenExpiration { get; set; }

/// <summary>
/// Gets or sets the users active state.
/// Whether this account is enabled for authentication. Defaults to true.
/// </summary>
public bool IsActive { get; init; } = true;

Expand Down
119 changes: 119 additions & 0 deletions src/Exceptionless.Core/Services/AuthService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using Exceptionless.DateTimeExtensions;
using Foundatio.Caching;

namespace Exceptionless.Core.Services;

/// <summary>
/// Tracks temporary password-login failures without changing the user's active state.
/// </summary>
/// <remarks>
/// Five failures per email address or fifteen per IP address block further password logins
/// until the next UTC quarter-hour boundary. Blocked requests do not extend this window.
/// </remarks>
public sealed class AuthService
{
private const int UserFailureLimit = 5;
private const int IpAddressFailureLimit = 15;
private static readonly TimeSpan AttemptWindow = TimeSpan.FromMinutes(15);
private readonly ScopedCacheClient _cache;
private readonly TimeProvider _timeProvider;

public AuthService(ICacheClient cacheClient, TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(cacheClient);
ArgumentNullException.ThrowIfNull(timeProvider);

_cache = new ScopedCacheClient(cacheClient, "Auth");
_timeProvider = timeProvider;
}

public async Task<LoginAttempt?> TryBeginLoginAsync(string emailAddress, string? ipAddress, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress);
if (ipAddress is not null)
ArgumentException.ThrowIfNullOrWhiteSpace(ipAddress);
cancellationToken.ThrowIfCancellationRequested();

var expiresUtc = GetWindowExpiration();
string[] userCacheKeys = GetUserCacheKeys(emailAddress, expiresUtc);
string[] ipAddressCacheKeys = ipAddress is null ? [] : GetIpAddressCacheKeys(ipAddress, expiresUtc);
var failures = await _cache.GetAllAsync<string>(userCacheKeys.Concat(ipAddressCacheKeys));
var userFailures = userCacheKeys.Where(key => failures[key].HasValue)
.Select(key => new KeyValuePair<string, string>(key, failures[key].Value)).ToArray();
if (userFailures.Length >= UserFailureLimit || ipAddressCacheKeys.Count(key => failures[key].HasValue) >= IpAddressFailureLimit)
return null;

return new LoginAttempt(expiresUtc, userCacheKeys, ipAddressCacheKeys, userFailures);
}

public async Task RecordLoginFailureAsync(LoginAttempt attempt)
{
ArgumentNullException.ThrowIfNull(attempt);

if (_timeProvider.GetUtcNow().UtcDateTime >= attempt.ExpiresUtc)
return;

// Atomic additions avoid mutable counter races and cap storage at the failure limits.
string failureId = Guid.NewGuid().ToString("N");
await Task.WhenAll(
RecordFailureAsync(attempt.UserCacheKeys, failureId, attempt.ExpiresUtc),
RecordFailureAsync(attempt.IpAddressCacheKeys, failureId, attempt.ExpiresUtc));
}

public Task RecordLoginSuccessAsync(LoginAttempt attempt)
{
ArgumentNullException.ThrowIfNull(attempt);

// Only clear failures seen when this attempt began; later failures belong to later attempts.
return RemoveFailuresAsync(attempt.UserFailures);
}

/// <summary>
/// Clears current user failures after password recovery without clearing IP failures
/// or changing the account's active state.
/// </summary>
public async Task ClearUserLoginAttemptsAsync(string emailAddress)
{
ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress);

var failures = await _cache.GetAllAsync<string>(GetUserCacheKeys(emailAddress, GetWindowExpiration()));
await RemoveFailuresAsync(failures.Where(pair => pair.Value.HasValue)
.Select(pair => new KeyValuePair<string, string>(pair.Key, pair.Value.Value)));
}

private async Task RecordFailureAsync(string[] cacheKeys, string failureId, DateTime expiresUtc)
{
foreach (string cacheKey in cacheKeys)
{
if (await _cache.AddAsync(cacheKey, failureId, expiresUtc))
return;
}
}

private Task RemoveFailuresAsync(IEnumerable<KeyValuePair<string, string>> failures)
=> Task.WhenAll(failures.Select(failure => _cache.RemoveIfEqualAsync(failure.Key, failure.Value)));

private DateTime GetWindowExpiration() => _timeProvider.GetUtcNow().UtcDateTime.Floor(AttemptWindow).Add(AttemptWindow);

private static string[] GetUserCacheKeys(string emailAddress, DateTime expiresUtc)
=> Enumerable.Range(0, UserFailureLimit).Select(slot => $"user:{emailAddress.Trim().ToLowerInvariant()}:failures:{expiresUtc.Ticks}:{slot}").ToArray();

private static string[] GetIpAddressCacheKeys(string ipAddress, DateTime expiresUtc)
=> Enumerable.Range(0, IpAddressFailureLimit).Select(slot => $"ip:{ipAddress}:failures:{expiresUtc.Ticks}:{slot}").ToArray();

public sealed class LoginAttempt
{
internal LoginAttempt(DateTime expiresUtc, string[] userCacheKeys, string[] ipAddressCacheKeys, KeyValuePair<string, string>[] userFailures)
{
ExpiresUtc = expiresUtc;
UserCacheKeys = userCacheKeys;
IpAddressCacheKeys = ipAddressCacheKeys;
UserFailures = userFailures;
}

internal DateTime ExpiresUtc { get; }
internal string[] UserCacheKeys { get; }
internal string[] IpAddressCacheKeys { get; }
internal KeyValuePair<string, string>[] UserFailures { get; }
}
}
25 changes: 0 additions & 25 deletions src/Exceptionless.Core/Services/OAuthClientMetadataService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,31 +155,6 @@ private static async Task<MemoryStream> ReadLimitedAsync(Stream stream, int maxB
}


public static bool IsPublicAddress(IPAddress address)
{
if (address.IsIPv4MappedToIPv6)
address = address.MapToIPv4();

if (IPAddress.IsLoopback(address) || IPAddress.Any.Equals(address) || IPAddress.IPv6Any.Equals(address) || IPAddress.IPv6Loopback.Equals(address))
return false;

if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
{
byte[] bytes = address.GetAddressBytes();
return !address.IsIPv6LinkLocal && !address.IsIPv6SiteLocal && (bytes[0] & 0xfe) != 0xfc;
}

byte[] octets = address.GetAddressBytes();
return octets[0] != 0
&& octets[0] != 10
&& octets[0] != 127
&& !(octets[0] == 169 && octets[1] == 254)
&& !(octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31)
&& !(octets[0] == 100 && octets[1] >= 64 && octets[1] <= 127)
&& !(octets[0] == 192 && octets[1] == 168)
&& !(octets[0] == 198 && (octets[1] == 18 || octets[1] == 19));
}

private static string GetCacheKey(string clientId)
{
return CachePrefix + GetCacheKeyHash(clientId);
Expand Down
21 changes: 13 additions & 8 deletions src/Exceptionless.Core/Services/OAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient,
AuthorizationRoles.OfflineAccess
];

private const string AuthorizationCodeCachePrefix = "oauth:code:";
private const string AuthorizationCodeCachePrefix = "code:";
private const string RefreshTokenLockPrefix = "oauth:refresh:";
private const string AccessTokenClientValidityCachePrefix = "oauth:client-valid:";
private const string AccessTokenClientValidityCachePrefix = "client-valid:";
private readonly ScopedCacheClient _cache = new(cacheClient, "oauth");
private const int OAuthGrantFamilyPageLimit = 1000;
private static readonly TimeSpan AccessTokenClientValidityCacheLifetime = TimeSpan.FromSeconds(30);
private const string ClientMetadataNotes = "Discovered from OAuth client metadata document.";
Expand Down Expand Up @@ -176,21 +177,21 @@ public async Task<bool> IsAccessTokenClientValidAsync(string? clientId)

clientId = clientId.Trim();
string cacheKey = GetAccessTokenClientValidityCacheKey(clientId);
bool? cached = await cacheClient.GetAsync<bool?>(cacheKey, null);
bool? cached = await _cache.GetAsync<bool?>(cacheKey, null);
if (cached.HasValue)
return cached.Value;

var application = await oauthApplicationRepository.GetByClientIdAsync(clientId);
bool isValid = application is { IsDisabled: false };
await cacheClient.SetAsync(cacheKey, isValid, AccessTokenClientValidityCacheLifetime);
await _cache.SetAsync(cacheKey, isValid, AccessTokenClientValidityCacheLifetime);
return isValid;
}

public Task ClearAccessTokenClientValidityCacheAsync(string? clientId)
{
return String.IsNullOrWhiteSpace(clientId)
? Task.CompletedTask
: cacheClient.RemoveAsync(GetAccessTokenClientValidityCacheKey(clientId.Trim()));
: _cache.RemoveAsync(GetAccessTokenClientValidityCacheKey(clientId.Trim()));
}

private async Task<OAuthClientOptions?> GetClientFromMetadataDocumentAsync(string clientId)
Expand Down Expand Up @@ -392,7 +393,7 @@ public async Task<string> CreateAuthorizationCodeAsync(OAuthAuthorizeRequest req
OrganizationIds = organizationIds,
CreatedUtc = timeProvider.GetUtcNow().UtcDateTime
};
await cacheClient.SetAsync(GetAuthorizationCodeCacheKey(code), authorizationCode, options.AuthorizationCodeLifetime);
await _cache.SetAsync(GetAuthorizationCodeCacheKey(code), authorizationCode, options.AuthorizationCodeLifetime);
return code;
}

Expand All @@ -411,12 +412,15 @@ public async Task<OAuthTokenIssueResult> ExchangeAuthorizationCodeAsync(OAuthTok
return OAuthTokenIssueResult.Invalid("invalid_grant", "Invalid PKCE verifier.");

string cacheKey = GetAuthorizationCodeCacheKey(request.Code);
var codeResult = await cacheClient.GetAsync<OAuthAuthorizationCode>(cacheKey);
var codeResult = await _cache.GetAsync<OAuthAuthorizationCode>(cacheKey);
if (!codeResult.HasValue)
return OAuthTokenIssueResult.Invalid("invalid_grant", "Authorization code is invalid or expired.");

await cacheClient.RemoveAsync(cacheKey);
if (!await _cache.RemoveAsync(cacheKey))
return OAuthTokenIssueResult.Invalid("invalid_grant", "Authorization code is invalid or expired.");

var code = codeResult.Value;

if (!String.Equals(code.ClientId, request.ClientId, StringComparison.Ordinal) || !String.Equals(code.RedirectUri, request.RedirectUri, StringComparison.Ordinal) || !String.Equals(code.Resource, request.Resource, StringComparison.Ordinal))
return OAuthTokenIssueResult.Invalid("invalid_grant", "Authorization code does not match the token request.");

Expand Down Expand Up @@ -763,6 +767,7 @@ private static string Base64UrlEncode(byte[] bytes)
return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}

// Preserve existing keys so codes issued before deployment remain redeemable.
private static string GetAuthorizationCodeCacheKey(string code) => AuthorizationCodeCachePrefix + code;
private static string GetRefreshTokenLockKey(string refreshToken) => RefreshTokenLockPrefix + CreateTokenHash(refreshToken);
private static string GetAccessTokenClientValidityCacheKey(string clientId) => AccessTokenClientValidityCachePrefix + Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(clientId))).TrimEnd('=').Replace('+', '-').Replace('/', '_');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Net.Sockets;
using Exceptionless.Core.Configuration;
using Exceptionless.Core.Extensions;
using Exceptionless.Core.Utility;
using Foundatio.Caching;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -68,7 +69,7 @@ internal async ValueTask<Stream> ConnectToPublicAddressAsync(SocketsHttpConnecti
bool addressThrottled = false;
foreach (var address in addresses)
{
if (!OAuthClientMetadataService.IsPublicAddress(address))
if (!PublicAddressPolicy.IsPublic(address))
continue;

string addressHash = address.ToString().ToSHA256();
Expand Down
43 changes: 43 additions & 0 deletions src/Exceptionless.Core/Utility/PublicAddressPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Net;
using System.Net.Sockets;

namespace Exceptionless.Core.Utility;

public static class PublicAddressPolicy
{
private static readonly IPNetwork GlobalIpv6 = IPNetwork.Parse("2000::/3");

// Special-use, private, documentation, and transition ranges must not be outbound destinations.
private static readonly IPNetwork[] BlockedIpv4 =
[
IPNetwork.Parse("0.0.0.0/8"), IPNetwork.Parse("10.0.0.0/8"),
IPNetwork.Parse("100.64.0.0/10"), IPNetwork.Parse("127.0.0.0/8"),
IPNetwork.Parse("169.254.0.0/16"), IPNetwork.Parse("172.16.0.0/12"),
IPNetwork.Parse("192.0.0.0/24"), IPNetwork.Parse("192.0.2.0/24"),
IPNetwork.Parse("192.88.99.0/24"), IPNetwork.Parse("192.168.0.0/16"),
IPNetwork.Parse("198.18.0.0/15"), IPNetwork.Parse("198.51.100.0/24"),
IPNetwork.Parse("203.0.113.0/24"), IPNetwork.Parse("224.0.0.0/4"),
IPNetwork.Parse("240.0.0.0/4")
];

private static readonly IPNetwork[] BlockedIpv6 =
[
IPNetwork.Parse("2001::/23"), IPNetwork.Parse("2001:db8::/32"),
IPNetwork.Parse("2002::/16"), IPNetwork.Parse("3fff::/20")
];

public static bool IsPublic(IPAddress address)
{
ArgumentNullException.ThrowIfNull(address);

if (address.IsIPv4MappedToIPv6)
address = address.MapToIPv4();

return address.AddressFamily switch
{
AddressFamily.InterNetwork => !BlockedIpv4.Any(network => network.Contains(address)),
AddressFamily.InterNetworkV6 => GlobalIpv6.Contains(address) && !BlockedIpv6.Any(network => network.Contains(address)),
_ => false
};
}
}
Loading
Loading