diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index d98b8eebfc..4bde0594f6 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -182,6 +182,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(s => s.GetRequiredService()); services.AddTransient(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -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(); @@ -231,7 +234,7 @@ private static async ValueTask 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 }; diff --git a/src/Exceptionless.Core/Models/User.cs b/src/Exceptionless.Core/Models/User.cs index 0a8f2c9143..ae4bec7739 100644 --- a/src/Exceptionless.Core/Models/User.cs +++ b/src/Exceptionless.Core/Models/User.cs @@ -48,7 +48,7 @@ public record User : IIdentity, IHaveDates, IValidatableObject public DateTime VerifyEmailAddressTokenExpiration { get; set; } /// - /// Gets or sets the users active state. + /// Whether this account is enabled for authentication. Defaults to true. /// public bool IsActive { get; init; } = true; diff --git a/src/Exceptionless.Core/Services/AuthService.cs b/src/Exceptionless.Core/Services/AuthService.cs new file mode 100644 index 0000000000..1d9aa86bdc --- /dev/null +++ b/src/Exceptionless.Core/Services/AuthService.cs @@ -0,0 +1,119 @@ +using Exceptionless.DateTimeExtensions; +using Foundatio.Caching; + +namespace Exceptionless.Core.Services; + +/// +/// Tracks temporary password-login failures without changing the user's active state. +/// +/// +/// 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. +/// +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 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(userCacheKeys.Concat(ipAddressCacheKeys)); + var userFailures = userCacheKeys.Where(key => failures[key].HasValue) + .Select(key => new KeyValuePair(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); + } + + /// + /// Clears current user failures after password recovery without clearing IP failures + /// or changing the account's active state. + /// + public async Task ClearUserLoginAttemptsAsync(string emailAddress) + { + ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress); + + var failures = await _cache.GetAllAsync(GetUserCacheKeys(emailAddress, GetWindowExpiration())); + await RemoveFailuresAsync(failures.Where(pair => pair.Value.HasValue) + .Select(pair => new KeyValuePair(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> 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[] userFailures) + { + ExpiresUtc = expiresUtc; + UserCacheKeys = userCacheKeys; + IpAddressCacheKeys = ipAddressCacheKeys; + UserFailures = userFailures; + } + + internal DateTime ExpiresUtc { get; } + internal string[] UserCacheKeys { get; } + internal string[] IpAddressCacheKeys { get; } + internal KeyValuePair[] UserFailures { get; } + } +} diff --git a/src/Exceptionless.Core/Services/OAuthClientMetadataService.cs b/src/Exceptionless.Core/Services/OAuthClientMetadataService.cs index 09b25702d2..f6d3da8ec2 100644 --- a/src/Exceptionless.Core/Services/OAuthClientMetadataService.cs +++ b/src/Exceptionless.Core/Services/OAuthClientMetadataService.cs @@ -155,31 +155,6 @@ private static async Task 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); diff --git a/src/Exceptionless.Core/Services/OAuthService.cs b/src/Exceptionless.Core/Services/OAuthService.cs index 77e7cb1446..0d98f0989e 100644 --- a/src/Exceptionless.Core/Services/OAuthService.cs +++ b/src/Exceptionless.Core/Services/OAuthService.cs @@ -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."; @@ -176,13 +177,13 @@ public async Task IsAccessTokenClientValidAsync(string? clientId) clientId = clientId.Trim(); string cacheKey = GetAccessTokenClientValidityCacheKey(clientId); - bool? cached = await cacheClient.GetAsync(cacheKey, null); + bool? cached = await _cache.GetAsync(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; } @@ -190,7 +191,7 @@ public Task ClearAccessTokenClientValidityCacheAsync(string? clientId) { return String.IsNullOrWhiteSpace(clientId) ? Task.CompletedTask - : cacheClient.RemoveAsync(GetAccessTokenClientValidityCacheKey(clientId.Trim())); + : _cache.RemoveAsync(GetAccessTokenClientValidityCacheKey(clientId.Trim())); } private async Task GetClientFromMetadataDocumentAsync(string clientId) @@ -392,7 +393,7 @@ public async Task 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; } @@ -411,12 +412,15 @@ public async Task ExchangeAuthorizationCodeAsync(OAuthTok return OAuthTokenIssueResult.Invalid("invalid_grant", "Invalid PKCE verifier."); string cacheKey = GetAuthorizationCodeCacheKey(request.Code); - var codeResult = await cacheClient.GetAsync(cacheKey); + var codeResult = await _cache.GetAsync(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."); @@ -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('/', '_'); diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapRequestThrottle.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapRequestThrottle.cs index 7cb158c94d..15571d0324 100644 --- a/src/Exceptionless.Core/Services/SourceMaps/SourceMapRequestThrottle.cs +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapRequestThrottle.cs @@ -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; @@ -68,7 +69,7 @@ internal async ValueTask ConnectToPublicAddressAsync(SocketsHttpConnecti bool addressThrottled = false; foreach (var address in addresses) { - if (!OAuthClientMetadataService.IsPublicAddress(address)) + if (!PublicAddressPolicy.IsPublic(address)) continue; string addressHash = address.ToString().ToSHA256(); diff --git a/src/Exceptionless.Core/Utility/PublicAddressPolicy.cs b/src/Exceptionless.Core/Utility/PublicAddressPolicy.cs new file mode 100644 index 0000000000..018cd3511f --- /dev/null +++ b/src/Exceptionless.Core/Utility/PublicAddressPolicy.cs @@ -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 + }; + } +} diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index e3c73ead83..5f999c4ca5 100644 --- a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs @@ -8,6 +8,7 @@ using Exceptionless.Core.Mail; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; using Exceptionless.DateTimeExtensions; using Exceptionless.Web.Api.Messages; using Exceptionless.Web.Extensions; @@ -30,6 +31,7 @@ public class AuthHandler( IOAuthTokenRepository oauthTokenRepository, IOAuthProviderClient oauthProviderClient, ICacheClient cacheClient, + AuthService authService, IMailer mailer, IDomainLoginProvider domainLoginProvider, TimeProvider timeProvider, @@ -46,21 +48,11 @@ public async Task> Handle(LoginMessage message) string email = model.Email.Trim().ToLowerInvariant(); using var _ = logger.BeginScope(new ExceptionlessState().Tag("Login").Identity(email).SetHttpContext(httpContext)); - string userLoginAttemptsCacheKey = $"user:{email}:attempts"; - long userLoginAttempts = await _cache.IncrementAsync(userLoginAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); - - string ipLoginAttemptsCacheKey = $"ip:{httpContext.Request.GetClientIpAddress()}:attempts"; - long ipLoginAttempts = await _cache.IncrementAsync(ipLoginAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); - - if (userLoginAttempts > 5) + string? ipAddress = httpContext.Request.GetClientIpAddress(); + var loginAttempt = await authService.TryBeginLoginAsync(email, ipAddress, httpContext.RequestAborted); + if (loginAttempt is null) { - logger.LogError("Login denied for {EmailAddress} for the {UserLoginAttempts} time", email, userLoginAttempts); - return Result.Unauthorized("Login denied."); - } - - if (ipLoginAttempts > 15) - { - logger.LogError("Login denied for {EmailAddress} for the {IPLoginAttempts} time", httpContext.Request.GetClientIpAddress(), ipLoginAttempts); + logger.LogError("Login denied for {EmailAddress}", email); return Result.Unauthorized("Login denied."); } @@ -77,12 +69,14 @@ public async Task> Handle(LoginMessage message) if (user is null) { + await authService.RecordLoginFailureAsync(loginAttempt); logger.LogError("Login failed for {EmailAddress}: User not found", email); return Result.Unauthorized("Login failed."); } if (!user.IsActive) { + await authService.RecordLoginFailureAsync(loginAttempt); logger.LogError("Login failed for {EmailAddress}: The user is inactive", user.EmailAddress); return Result.Unauthorized("Login failed."); } @@ -91,18 +85,21 @@ public async Task> Handle(LoginMessage message) { if (String.IsNullOrEmpty(user.Salt)) { + await authService.RecordLoginFailureAsync(loginAttempt); logger.LogError("Login failed for {EmailAddress}: The user has no salt defined", user.EmailAddress); return Result.Unauthorized("Login failed."); } if (!user.IsCorrectPassword(model.Password)) { + await authService.RecordLoginFailureAsync(loginAttempt); logger.LogError("Login failed for {EmailAddress}: Invalid Password", user.EmailAddress); return Result.Unauthorized("Login failed."); } } else if (!IsValidActiveDirectoryLogin(email, model.Password)) { + await authService.RecordLoginFailureAsync(loginAttempt); logger.LogError("Domain login failed for {EmailAddress}: Invalid Password or Account", user.EmailAddress); return Result.Unauthorized("Login failed."); } @@ -110,8 +107,7 @@ public async Task> Handle(LoginMessage message) if (!String.IsNullOrEmpty(model.InviteToken)) await AddInvitedUserToOrganizationAsync(model.InviteToken, user, httpContext); - await _cache.RemoveAsync(userLoginAttemptsCacheKey); - await _cache.DecrementAsync(ipLoginAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); + await authService.RecordLoginSuccessAsync(loginAttempt); logger.UserLoggedIn(user.EmailAddress); return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; @@ -362,13 +358,7 @@ public async Task> Handle(ChangePassword message) await ChangePasswordAsync(user, model.Password!, nameof(ChangePasswordAsync), httpContext); await ResetUserTokensAsync(user, nameof(ChangePasswordAsync), httpContext); - string userLoginAttemptsCacheKey = $"user:{user.EmailAddress}:attempts"; - await _cache.RemoveAsync(userLoginAttemptsCacheKey); - - string ipLoginAttemptsCacheKey = $"ip:{httpContext.Request.GetClientIpAddress()}:attempts"; - long attempts = await _cache.DecrementAsync(ipLoginAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); - if (attempts <= 0) - await _cache.RemoveAsync(ipLoginAttemptsCacheKey); + await authService.ClearUserLoginAttemptsAsync(user.EmailAddress); logger.UserChangedPassword(user.EmailAddress); return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }; @@ -464,13 +454,7 @@ public async Task Handle(ResetPassword message) await ChangePasswordAsync(user, model.Password!, "ResetPasswordAsync", httpContext); await ResetUserTokensAsync(user, "ResetPasswordAsync", httpContext); - string userLoginAttemptsCacheKey = $"user:{user.EmailAddress}:attempts"; - await _cache.RemoveAsync(userLoginAttemptsCacheKey); - - string ipLoginAttemptsCacheKey = $"ip:{httpContext.Request.GetClientIpAddress()}:attempts"; - long attempts = await _cache.DecrementAsync(ipLoginAttemptsCacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromMinutes(15))); - if (attempts <= 0) - await _cache.RemoveAsync(ipLoginAttemptsCacheKey); + await authService.ClearUserLoginAttemptsAsync(user.EmailAddress); logger.UserResetPassword(user.EmailAddress); return Result.Success(); @@ -536,6 +520,11 @@ private async Task> ExternalLoginAsync(ExternalAuthInfo auth { user = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext); } + catch (UnauthorizedAccessException ex) + { + logger.LogError(ex, "External login failed for {EmailAddress}: The user is inactive", userInfo.Email); + return Result.Unauthorized("Login failed."); + } catch (ApplicationException ex) { logger.LogCritical(ex, "External login failed for {EmailAddress}: {Message}", userInfo.Email, ex.Message); @@ -563,9 +552,14 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id); using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").Tag(userInfo.ProviderName).Identity(userInfo.Email).SetHttpContext(httpContext)); + if (existingUser is not null) + EnsureUserIsActive(existingUser); + if (String.IsNullOrWhiteSpace(inviteToken) && httpContext.User.IsUserAuthType()) { var currentUser = httpContext.Request.GetUser(); + EnsureUserIsActive(currentUser); + if (existingUser is not null) { if (existingUser.Id != currentUser.Id) @@ -608,6 +602,7 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit await AddGlobalAdminRoleIfFirstUserAsync(user); } + EnsureUserIsActive(user); user.MarkEmailAddressVerified(); user.AddOAuthAccount(userInfo.ProviderName, userInfo.Id, userInfo.Email); @@ -619,6 +614,12 @@ private async Task FromExternalLoginAsync(UserInfo userInfo, string? invit return user; } + private static void EnsureUserIsActive(User user) + { + if (!user.IsActive) + throw new UnauthorizedAccessException("The user is inactive."); + } + private async Task IsAccountCreationEnabledAsync(string? token) { if (authOptions.EnableAccountCreation) diff --git a/src/Exceptionless.Web/Api/Handlers/WebHookHandler.cs b/src/Exceptionless.Web/Api/Handlers/WebHookHandler.cs index 6ceaa0ff84..4ee0f72eeb 100644 --- a/src/Exceptionless.Web/Api/Handlers/WebHookHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/WebHookHandler.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using Exceptionless.Core.Authorization; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -86,8 +88,8 @@ public async Task> Handle(DeleteWebHooks message) public async Task> Handle(SubscribeWebHook message) { - string? eventType = message.Data.RootElement.TryGetProperty("event", out var eventProp) ? eventProp.GetString() : null; - string? url = message.Data.RootElement.TryGetProperty("target_url", out var urlProp) ? urlProp.GetString() : null; + string? eventType = GetStringProperty(message.Data.RootElement, "event"); + string? url = GetStringProperty(message.Data.RootElement, "target_url"); if (String.IsNullOrEmpty(eventType) || String.IsNullOrEmpty(url)) return Result.BadRequest("Webhook subscription event and target_url are required."); @@ -108,7 +110,7 @@ public async Task> Handle(DeleteWebHooks message) Version = new Version(message.ApiVersion >= 0 ? message.ApiVersion : 0, 0) }; - if (!webHook.Url.StartsWith("https://hooks.zapier.com")) + if (!IsZapierTarget(webHook.Url)) return Result.NotFound("Webhook target not found."); return await PostImplAsync(webHook); @@ -116,8 +118,8 @@ public async Task> Handle(DeleteWebHooks message) public async Task Handle(UnsubscribeWebHook message) { - string? targetUrl = message.Data.RootElement.TryGetProperty("target_url", out var urlProp) ? urlProp.GetString() : null; - if (targetUrl is null || !targetUrl.StartsWith("https://hooks.zapier.com")) + string? targetUrl = GetStringProperty(message.Data.RootElement, "target_url"); + if (!IsZapierTarget(targetUrl)) return Result.NotFound("Webhook target not found."); var results = await repository.GetByUrlAsync(targetUrl); @@ -127,13 +129,29 @@ public async Task Handle(UnsubscribeWebHook message) if (results.Documents.Any(h => h.OrganizationId != organizationId)) throw new ArgumentException("All OrganizationIds must be the same."); - _logger.RemovingZapierUrls(results.Documents.Count, targetUrl); + _logger.RemovingZapierUrls(results.Documents.Count, organizationId); await repository.RemoveAsync(results.Documents); } return Result.Success(); } + private static string? GetStringProperty(JsonElement data, string name) + => data.ValueKind == JsonValueKind.Object && data.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static bool IsZapierTarget([NotNullWhen(true)] string? url) + { + return url is not null + && url.StartsWith("https://hooks.zapier.com", StringComparison.Ordinal) + && Uri.TryCreate(url, UriKind.Absolute, out var uri) + && uri.Scheme == Uri.UriSchemeHttps + && uri.Host.Equals("hooks.zapier.com", StringComparison.Ordinal) + && uri.IsDefaultPort + && String.IsNullOrEmpty(uri.UserInfo); + } + public Result Handle(TestWebHook message) { return new object[] { diff --git a/src/Exceptionless.Web/Extensions/HttpExtensions.cs b/src/Exceptionless.Web/Extensions/HttpExtensions.cs index c38281f3ae..80b4fb3fd5 100644 --- a/src/Exceptionless.Web/Extensions/HttpExtensions.cs +++ b/src/Exceptionless.Web/Extensions/HttpExtensions.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Net; +using System.Net.Http.Headers; using System.Security.Claims; using System.Text; using Exceptionless.Core.Authorization; @@ -164,19 +165,24 @@ public static ICollection GetAssociatedOrganizationIds(this HttpRequest ArgumentNullException.ThrowIfNull(request); string? authHeader = request.Headers.TryGetAndReturn("Authorization"); - if (authHeader is null || !authHeader.StartsWith("basic", StringComparison.OrdinalIgnoreCase)) + if (!AuthenticationHeaderValue.TryParse(authHeader, out var header) + || !String.Equals(header.Scheme, "Basic", StringComparison.OrdinalIgnoreCase) + || String.IsNullOrWhiteSpace(header.Parameter)) return null; - string token = authHeader.Substring(6).Trim(); - string credentialString = Encoding.UTF8.GetString(Convert.FromBase64String(token)); - string[] credentials = credentialString.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (credentials.Length != 2) + byte[] credentialBytes = new byte[header.Parameter.Length]; + if (!Convert.TryFromBase64String(header.Parameter, credentialBytes, out int bytesWritten)) + return null; + + string credentialString = Encoding.UTF8.GetString(credentialBytes, 0, bytesWritten); + int separator = credentialString.IndexOf(':'); + if (separator <= 0 || String.IsNullOrWhiteSpace(credentialString[..separator])) return null; return new AuthInfo { - Username = credentials[0], - Password = credentials[1] + Username = credentialString[..separator], + Password = credentialString[(separator + 1)..] }; } diff --git a/src/Exceptionless.Web/Extensions/LoggerExtensions.cs b/src/Exceptionless.Web/Extensions/LoggerExtensions.cs index d4b8f36550..50a87d9a79 100644 --- a/src/Exceptionless.Web/Extensions/LoggerExtensions.cs +++ b/src/Exceptionless.Web/Extensions/LoggerExtensions.cs @@ -12,7 +12,7 @@ internal static class LoggerExtensions LoggerMessage.Define( LogLevel.Information, new EventId(1, nameof(RemovingZapierUrls)), - "Removing {Count} zapier urls matching: {Url}"); + "Removing {Count} Zapier webhooks for organization {OrganizationId}"); private static readonly Action _removedTokens = LoggerMessage.Define( @@ -113,8 +113,8 @@ internal static class LoggerExtensions public static void ProjectRouteDoesNotMatch(this ILogger logger, string? requestProjectId, string routeProjectId) => _projectRouteDoesNotMatch(logger, requestProjectId, routeProjectId, null); - public static void RemovingZapierUrls(this ILogger logger, int count, string targetUrl) - => _removingZapierUrls(logger, count, targetUrl, null); + public static void RemovingZapierUrls(this ILogger logger, int count, string organizationId) + => _removingZapierUrls(logger, count, organizationId, null); public static void RemovedTokens(this ILogger logger, long removedCount, string userId) => _removedTokens(logger, removedCount, userId, null); diff --git a/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs index 1511233242..6f7941aa19 100644 --- a/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs +++ b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs @@ -32,17 +32,19 @@ public class ApiKeyAuthenticationHandler : AuthenticationHandler options, + public ApiKeyAuthenticationHandler(ITokenRepository tokenRepository, IOAuthTokenRepository oauthTokenRepository, ICacheClient cacheClient, AuthService authService, IUserRepository userRepository, OAuthService oauthService, AppOptions appOptions, IOptionsMonitor options, TimeProvider timeProvider, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { _tokenRepository = tokenRepository; _oauthTokenRepository = oauthTokenRepository; _cacheClient = cacheClient; + _authService = authService; _userRepository = userRepository; _oauthService = oauthService; _appOptions = appOptions; @@ -67,36 +69,46 @@ protected override async Task HandleAuthenticateAsync() else if (authHeader is not null && scheme == BasicScheme) { var authInfo = Request.GetBasicAuth(); - if (authInfo is not null) + if (authInfo is null) { - if (authInfo.Username.ToLower() == "client") - token = authInfo.Password; - else if (authInfo.Password.ToLower() == "x-oauth-basic" || String.IsNullOrEmpty(authInfo.Password)) - token = authInfo.Username; - else + Logger.LogDebug("Invalid Basic authentication credentials on {Path}", Request.Path); + return AuthenticateResult.NoResult(); + } + + if (String.Equals(authInfo.Username, "client", StringComparison.OrdinalIgnoreCase)) + token = authInfo.Password; + else if (String.Equals(authInfo.Password, "x-oauth-basic", StringComparison.OrdinalIgnoreCase) || String.IsNullOrEmpty(authInfo.Password)) + token = authInfo.Username; + else + { + string emailAddress = authInfo.Username.Trim().ToLowerInvariant(); + string? ipAddress = Request.GetClientIpAddress(); + var loginAttempt = await _authService.TryBeginLoginAsync(emailAddress, ipAddress, Context.RequestAborted); + if (loginAttempt is null) { - User? user; - try - { - user = await _userRepository.GetByEmailAddressAsync(authInfo.Username); - } - catch (Exception ex) - { - return AuthenticateResult.Fail(ex); - } - - if (user is not { IsActive: true }) - return AuthenticateResult.Fail("User is not valid"); - - if (String.IsNullOrEmpty(user.Salt)) - return AuthenticateResult.Fail("User is not valid"); - - string encodedPassword = authInfo.Password.ToSaltedHash(user.Salt); - if (!String.Equals(encodedPassword, user.Password)) - return AuthenticateResult.Fail("User is not valid"); - - return AuthenticateResult.Success(CreateUserAuthenticationTicket(user)); + Logger.LogError("Login denied for {EmailAddress}", emailAddress); + return AuthenticateResult.Fail("Login denied."); } + + User? user; + try + { + user = await _userRepository.GetByEmailAddressAsync(emailAddress); + } + catch (Exception ex) + { + return AuthenticateResult.Fail(ex); + } + + if (user is not { IsActive: true } || !user.IsCorrectPassword(authInfo.Password)) + { + await _authService.RecordLoginFailureAsync(loginAttempt); + return AuthenticateResult.Fail("User is not valid"); + } + + await _authService.RecordLoginSuccessAsync(loginAttempt); + + return AuthenticateResult.Success(CreateUserAuthenticationTicket(user)); } } else @@ -147,6 +159,12 @@ protected override async Task HandleAuthenticateAsync() return AuthenticateResult.Fail("Token is not valid"); } + if (!user.IsActive) + { + Logger.LogInformation("Inactive user {UserId} for token on {Path}", tokenRecord.UserId, Request.Path); + return AuthenticateResult.Fail("Token is not valid"); + } + return AuthenticateResult.Success(CreateUserAuthenticationTicket(user, tokenRecord)); } diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index 1cc6d7a85f..35c6fceec5 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -1,5 +1,10 @@ using System.IdentityModel.Tokens.Jwt; using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; using Exceptionless.Core.Authorization; using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; @@ -13,10 +18,13 @@ using Exceptionless.Tests.Extensions; using Exceptionless.Tests.Utility; using Exceptionless.Web.Models; +using Exceptionless.Web.Security; using FluentRest; using Foundatio.Queues; using Foundatio.Repositories; using Foundatio.Repositories.Utility; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Xunit; using User = Exceptionless.Core.Models.User; @@ -560,6 +568,505 @@ public async Task LiveAsync_WithConfiguredProvider_ReturnsToken() await AssertExternalLoginAsync(result, "windowslive", code); } + [Fact] + public async Task AuthenticateAsync_InactiveUserToken_ReturnsUnauthorized() + { + // Arrange + const string email = "inactive-token-user@exceptionless.test"; + const string password = "Password1$"; + const string salt = "1234567890123456"; + + var user = new User + { + EmailAddress = email, + Password = password.ToSaltedHash(salt), + Salt = salt, + FullName = "Inactive Token User", + Roles = AuthorizationRoles.AllScopes + }; + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); + + var login = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login { Email = email, Password = password }) + .StatusCodeShouldBeOk()); + Assert.NotNull(login); + Assert.False(String.IsNullOrEmpty(login.Token)); + + var storedUser = await _userRepository.GetByEmailAddressAsync(email); + Assert.NotNull(storedUser); + await _userRepository.SaveAsync(storedUser with { IsActive = false }, o => o.ImmediateConsistency()); + + // Act + var response = await SendRequestAsync(r => r + .BearerToken(login.Token) + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExternalLogin_InactiveUser_IsRejectedWithoutChangingAccountOrInvite(bool hasExistingLink) + { + // Arrange + const string code = "github-inactive-user"; + string email = TestOAuthProviderClient.GetEmailAddress(code); + var user = new User + { + EmailAddress = email, + FullName = "Inactive User", + IsActive = false, + Roles = new HashSet([AuthorizationRoles.Client, AuthorizationRoles.User]) + }; + user.ResetVerifyEmailAddressTokenAndExpiration(TimeProvider); + if (hasExistingLink) + user.AddOAuthAccount("github", code, email); + await _userRepository.AddAsync(user, options => options.ImmediateConsistency()); + var organization = (await _organizationRepository.GetAllAsync()).Documents.First(); + var invite = new Invite + { + Token = StringExtensions.GetNewToken(), + EmailAddress = email, + DateAdded = TimeProvider.GetUtcNow().UtcDateTime + }; + organization.Invites.Add(invite); + await _organizationRepository.SaveAsync(organization, options => options.ImmediateConsistency()); + + // Act + await SendRequestAsync(request => request + .Post() + .AppendPaths("auth", "github") + .Content(new ExternalAuthInfo + { + ClientId = "client-id", + Code = code, + InviteToken = invite.Token, + RedirectUri = "http://localhost/callback" + }) + .StatusCodeShouldBeUnauthorized()); + + // Assert + var storedUser = await _userRepository.GetByIdAsync(user.Id, options => options.ImmediateConsistency()); + Assert.NotNull(storedUser); + Assert.False(storedUser.IsActive); + Assert.False(storedUser.IsEmailAddressVerified); + Assert.Equal(user.VerifyEmailAddressToken, storedUser.VerifyEmailAddressToken); + Assert.Equal(user.VerifyEmailAddressTokenExpiration, storedUser.VerifyEmailAddressTokenExpiration); + Assert.Equal(user.OAuthAccounts.Select(account => (account.Provider, account.ProviderUserId, account.Username)), + storedUser.OAuthAccounts.Select(account => (account.Provider, account.ProviderUserId, account.Username))); + Assert.Empty(storedUser.OrganizationIds); + var tokens = await _tokenRepository.GetByTypeAndUserIdAsync(TokenType.Authentication, user.Id, options => options.ImmediateConsistency()); + Assert.Empty(tokens.Documents); + var storedOrganization = await _organizationRepository.GetByIdAsync(organization.Id, options => options.ImmediateConsistency()); + Assert.NotNull(storedOrganization); + Assert.Contains(storedOrganization.Invites, candidate => candidate.Token == invite.Token); + } + + [Fact] + public async Task ExternalLogin_InactiveProviderOwner_DoesNotTransferAccount() + { + // Arrange + const string code = "github-inactive-provider-owner"; + var owner = new User + { + EmailAddress = TestOAuthProviderClient.GetEmailAddress(code), + FullName = "Inactive Owner", + IsActive = false + }; + owner.ResetVerifyEmailAddressTokenAndExpiration(TimeProvider); + owner.AddOAuthAccount("github", code, owner.EmailAddress); + await _userRepository.AddAsync(owner, options => options.ImmediateConsistency()); + var currentUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(currentUser); + var originalAccounts = currentUser.OAuthAccounts.Select(account => (account.Provider, account.ProviderUserId, account.Username)).ToArray(); + long originalTokenCount = (await _tokenRepository.CountAsync()).Total; + + // Act + await SendRequestAsync(request => request + .Post() + .AsTestOrganizationUser() + .AppendPaths("auth", "github") + .Content(new ExternalAuthInfo + { + ClientId = "client-id", + Code = code, + RedirectUri = "http://localhost/callback" + }) + .StatusCodeShouldBeUnauthorized()); + + // Assert + var storedOwner = await _userRepository.GetByIdAsync(owner.Id, options => options.ImmediateConsistency()); + Assert.NotNull(storedOwner); + Assert.False(storedOwner.IsEmailAddressVerified); + Assert.Equal(owner.VerifyEmailAddressToken, storedOwner.VerifyEmailAddressToken); + Assert.Equal(code, Assert.Single(storedOwner.OAuthAccounts).ProviderUserId); + var storedCurrentUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.ImmediateConsistency()); + Assert.NotNull(storedCurrentUser); + Assert.Equal(originalAccounts, storedCurrentUser.OAuthAccounts.Select(account => (account.Provider, account.ProviderUserId, account.Username))); + Assert.Equal(originalTokenCount, (await _tokenRepository.CountAsync()).Total); + } + + [Fact] + public async Task PasswordLogin_MissingRemoteIpAddress_StillEnforcesUserLimit() + { + // Arrange + using var client = _server.CreateClient(); + long originalTokenCount = (await _tokenRepository.CountAsync()).Total; + for (int attempt = 0; attempt < 5; attempt++) + { + using var failedResponse = await client.PostAsJsonAsync("api/v2/auth/login", + new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = "wrong-password" }, + TestCancellationToken); + Assert.Equal(HttpStatusCode.Unauthorized, failedResponse.StatusCode); + } + + // Act + using var response = await client.PostAsJsonAsync("api/v2/auth/login", + new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }, + TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(originalTokenCount, (await _tokenRepository.CountAsync()).Total); + } + + [Fact] + public async Task PasswordLogin_ThrottlingExpires_LogsInWithoutPasswordResetOrReactivation() + { + // Arrange + TimeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 14, 0, TimeSpan.Zero)); + using var client = _server.CreateClient(); + for (int failure = 0; failure < 5; failure++) + { + using var response = await client.PostAsJsonAsync("api/v2/auth/login", + new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = "wrong-password" }, + TestCancellationToken); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + var credentials = new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }; + + // Act + using var blocked = await client.PostAsJsonAsync("api/v2/auth/login", credentials, TestCancellationToken); + var throttledUser = await _userRepository.GetByEmailAddressAsync(credentials.Email); + TimeProvider.Advance(TimeSpan.FromMinutes(1)); + using var allowed = await client.PostAsJsonAsync("api/v2/auth/login", credentials, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, blocked.StatusCode); + Assert.NotNull(throttledUser); + Assert.True(throttledUser.IsActive); + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + } + + [Theory] + [InlineData(false, HttpStatusCode.Unauthorized)] + [InlineData(true, HttpStatusCode.OK)] + public async Task ResetPassword_PreservesActiveState_OnlyActiveUsersCanLogIn(bool isActive, HttpStatusCode expectedStatus) + { + // Arrange + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(user); + user = user with { IsActive = isActive }; + user.CreatePasswordResetToken(TimeProvider); + Assert.NotNull(user.PasswordResetToken); + await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); + using var client = _server.CreateClient(); + const string newPassword = "Password2$"; + + // Act + using var reset = await client.PostAsJsonAsync("api/v2/auth/reset-password", + new ResetPasswordModel { PasswordResetToken = user.PasswordResetToken, Password = newPassword }, + GetService(), TestCancellationToken); + var storedUser = await _userRepository.GetByIdAsync(user.Id, options => options.ImmediateConsistency()); + using var login = await client.PostAsJsonAsync("api/v2/auth/login", + new Login { Email = user.EmailAddress, Password = newPassword }, TestCancellationToken); + using var basicRequest = new HttpRequestMessage(HttpMethod.Get, "api/v2/users/me"); + basicRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user.EmailAddress}:{newPassword}"))); + using var basicLogin = await client.SendAsync(basicRequest, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, reset.StatusCode); + Assert.NotNull(storedUser); + Assert.Equal(isActive, storedUser.IsActive); + Assert.True(storedUser.IsCorrectPassword(newPassword)); + Assert.Equal(expectedStatus, login.StatusCode); + Assert.Equal(expectedStatus, basicLogin.StatusCode); + } + + [Fact] + public async Task ResetPassword_MissingRemoteIpAddress_ClearsUserLoginAttempts() + { + // Arrange + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(user); + user.CreatePasswordResetToken(TimeProvider); + Assert.NotNull(user.PasswordResetToken); + await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); + var authService = GetService(); + for (int failure = 0; failure < 5; failure++) + { + var loginAttempt = await authService.TryBeginLoginAsync(user.EmailAddress, "192.0.2.1", TestCancellationToken); + Assert.NotNull(loginAttempt); + await authService.RecordLoginFailureAsync(loginAttempt); + } + using var client = _server.CreateClient(); + + // Act + using var response = await client.PostAsJsonAsync("api/v2/auth/reset-password", + new ResetPasswordModel { PasswordResetToken = user.PasswordResetToken, Password = "Password2$" }, + GetService(), + TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(await authService.TryBeginLoginAsync(user.EmailAddress, "192.0.2.1", TestCancellationToken)); + } + + [Fact] + public async Task BasicPasswordLogin_ConcurrentValidRequests_AllSucceed() + { + // Arrange + using var client = _server.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{SampleDataService.TEST_USER_EMAIL}:{SampleDataService.TEST_USER_PASSWORD}"))); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var requests = Enumerable.Range(0, 30).Select(async request => + { + await start.Task; + using var response = await client.GetAsync("api/v2/users/me", TestCancellationToken); + return response.StatusCode; + }).ToArray(); + + // Act + start.SetResult(); + var results = await Task.WhenAll(requests); + + // Assert + Assert.All(results, statusCode => Assert.Equal(HttpStatusCode.OK, statusCode)); + } + + [Fact] + public async Task BasicPasswordLogin_MissingRemoteIpAddress_ReturnsCurrentUser() + { + // Arrange + using var client = _server.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "api/v2/users/me"); + string credentials = $"{SampleDataService.TEST_USER_EMAIL}:{SampleDataService.TEST_USER_PASSWORD}"; + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes(credentials))); + + // Act + using var response = await client.SendAsync(request, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task PasswordLogin_FailuresThroughBasic_AreThrottled() + { + // Arrange + for (int attempt = 0; attempt < 5; attempt++) + { + await SendRequestAsync(request => request + .BasicAuthorization(SampleDataService.TEST_USER_EMAIL, "wrong-password") + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + } + + // Act + var response = await SendRequestAsync(request => request + .Post() + .AppendPath("auth/login") + .Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }) + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task BasicPasswordLogin_FailuresThroughLogin_AreThrottled() + { + // Arrange + for (int attempt = 0; attempt < 5; attempt++) + { + await SendRequestAsync(request => request + .Post() + .AppendPath("auth/login") + .Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = "wrong-password" }) + .StatusCodeShouldBeUnauthorized()); + } + + // Act + var response = await SendRequestAsync(request => request + .BasicAuthorization(SampleDataService.TEST_USER_EMAIL, SampleDataService.TEST_USER_PASSWORD) + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task BasicPasswordLogin_AfterRepeatedFailures_IsThrottled() + { + // Arrange + const string email = "basic-throttle-user@exceptionless.test"; + const string password = "Password1$"; + const string salt = "1234567890123456"; + + var user = new User + { + EmailAddress = email, + Password = password.ToSaltedHash(salt), + Salt = salt, + FullName = "Basic Throttle User", + Roles = AuthorizationRoles.AllScopes + }; + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); + + for (int attempt = 0; attempt < 5; attempt++) + { + await SendRequestAsync(r => r + .BasicAuthorization(email, "wrong-password") + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + } + + // Act + var response = await SendRequestAsync(r => r + .BasicAuthorization(email, password) + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [InlineData("Basic !!!not-base64!!!")] + [InlineData("Basic")] + [InlineData("Basic ")] + [InlineData("Basic Og==")] + [InlineData("Basic ICA6cGFzc3dvcmQ=")] + public async Task BasicAuthentication_MalformedHeader_ReturnsUnauthorized(string authorization) + { + // Arrange + using var client = CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "users/me"); + request.Headers.TryAddWithoutValidation("Authorization", authorization); + + // Act + using var response = await client.SendAsync(request, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [InlineData("Basic")] + [InlineData("Basic !!!not-base64!!!")] + [InlineData("Basic Og==")] + public async Task BasicAuthentication_UnparseableCredentials_ReturnsNoResult(string authorization) + { + // Arrange + using var scope = _server.Services.CreateScope(); + var context = new DefaultHttpContext { RequestServices = scope.ServiceProvider }; + context.Request.Headers.Authorization = authorization; + context.Request.QueryString = new QueryString("?api_key=" + SampleDataService.TEST_API_KEY); + + // Act + var result = await context.AuthenticateAsync(ApiKeyAuthenticationOptions.ApiKeySchema); + + // Assert + Assert.True(result.None); + Assert.Null(result.Failure); + Assert.Null(result.Principal); + } + + [Theory] + [InlineData(null, "hash")] + [InlineData("", "hash")] + [InlineData("salt", null)] + [InlineData("salt", "")] + public async Task BasicPasswordLogin_MissingStoredCredentials_ReturnsUnauthorized(string? salt, string? passwordHash) + { + // Arrange + var user = new User + { + EmailAddress = "missing-credentials@exceptionless.test", + FullName = "Missing Credentials", + Salt = salt, + Password = passwordHash, + Roles = new HashSet([AuthorizationRoles.Client, AuthorizationRoles.User]) + }; + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user, options => options.ImmediateConsistency()); + + // Act + var response = await SendRequestAsync(request => request + .BasicAuthorization(user.EmailAddress, "Password1$") + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [InlineData("Basic", "client:{token}")] + [InlineData("Basic", "{token}:")] + [InlineData("bAsIc", "{token}:x-oauth-basic")] + [InlineData("Basic ", "client:{token}")] + public async Task BasicAuthentication_AuthenticationToken_ReturnsCurrentUser(string scheme, string credentialsTemplate) + { + // Arrange + var login = await SendRequestAsAsync(request => request + .Post() + .AppendPath("auth/login") + .Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }) + .StatusCodeShouldBeOk()); + Assert.NotNull(login); + string credentials = credentialsTemplate.Replace("{token}", login.Token, StringComparison.Ordinal); + using var client = CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "users/me"); + request.Headers.TryAddWithoutValidation("Authorization", $"{scheme} {Convert.ToBase64String(Encoding.UTF8.GetBytes(credentials))}"); + + // Act + using var response = await client.SendAsync(request, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task BasicAuthentication_PasswordContainingColons_ReturnsCurrentUser() + { + // Arrange + const string password = "Pass:word:1$"; + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(user); + Assert.NotNull(user.Salt); + user.Password = password.ToSaltedHash(user.Salt); + await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); + + // Act + var response = await SendRequestAsync(request => request + .BasicAuthorization(user.EmailAddress, password).AppendPath("users/me").StatusCodeShouldBeOk()); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + [Fact] public async Task GitHubAsync_WithoutInviteAndAuthenticatedSession_LinksCurrentUser() { diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs index e3272018c5..2ff220b3ff 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs @@ -14,6 +14,7 @@ using Exceptionless.Web.Models.Admin; using Exceptionless.Web.Models.OAuth; using FluentRest; +using Foundatio.Caching; using Foundatio.Repositories; using Foundatio.Repositories.Utility; using Microsoft.AspNetCore.WebUtilities; @@ -1547,6 +1548,67 @@ await SendRequestAsync(r => r ); } + [Fact] + public void MetadataClient_Transport_UsesPublicAddressConnectionWithoutProxyCookiesOrRedirects() + { + // Arrange + var factory = GetService(); + + // Act + HttpMessageHandler handler = factory.CreateHandler(nameof(IOAuthClientMetadataService)); + while (handler is DelegatingHandler delegatingHandler) + handler = Assert.IsAssignableFrom(delegatingHandler.InnerHandler); + + // Assert + var socketsHandler = Assert.IsType(handler); + Assert.False(socketsHandler.AllowAutoRedirect); + Assert.False(socketsHandler.UseCookies); + Assert.False(socketsHandler.UseProxy); + Assert.NotNull(socketsHandler.ConnectCallback); + } + + [Fact] + public async Task TokenAsync_AuthorizationCodeCache_KeepsExistingKeyFormat() + { + // Arrange + string code = await CreateAuthorizationCodeAsync(PkceVerifier); + string cacheKey = $"oauth:code:{code}"; + var cache = GetService(); + Assert.True((await cache.GetAsync(cacheKey)).HasValue); + using var client = CreateHttpClient(); + using var exchangeContent = CreateTokenExchangeContent(code, PkceVerifier); + + // Act + using var response = await client.PostAsync("oauth/token", exchangeContent, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False((await cache.GetAsync(cacheKey)).HasValue); + } + + [Fact] + public async Task TokenAsync_ConcurrentAuthorizationCodeUse_OnlyOneSucceeds() + { + // Arrange + string code = await CreateAuthorizationCodeAsync(PkceVerifier); + using var client = CreateHttpClient(); + using var firstExchangeContent = CreateTokenExchangeContent(code, PkceVerifier); + using var secondExchangeContent = CreateTokenExchangeContent(code, PkceVerifier); + + // Act + var responses = await Task.WhenAll( + client.PostAsync("oauth/token", firstExchangeContent, TestCancellationToken), + client.PostAsync("oauth/token", secondExchangeContent, TestCancellationToken)); + + // Assert + Assert.Equal(1, responses.Count(r => r.StatusCode == HttpStatusCode.OK)); + Assert.Equal(1, responses.Count(r => r.StatusCode == HttpStatusCode.BadRequest)); + var failedResponse = responses.Single(r => r.StatusCode == HttpStatusCode.BadRequest); + var error = await failedResponse.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(error); + Assert.Equal("invalid_grant", error.Error); + } + private async Task RemoveTestUserFromOrganizationAsync(string organizationId) { var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/WebHookEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/WebHookEndpointTests.cs index 0ebddc9914..345e297461 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/WebHookEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/WebHookEndpointTests.cs @@ -568,6 +568,7 @@ public async Task Test_WithGetRequest_ReturnsZapierTestMessages() public async Task UnsubscribeAsync_ExistingZapierHook_RemovesWebHook() { // Arrange - create a zapier hook via subscribe + var logger = Assert.IsType(GetService()); const string zapierUrl = "https://hooks.zapier.com/hooks/unsubtest"; var webHook = await SendRequestAsAsync(r => r .Post() @@ -597,6 +598,74 @@ await SendRequestAsync(r => r await RefreshDataAsync(); var results = await _webHookRepository.GetByUrlAsync(zapierUrl); Assert.Empty(results.Documents); + var unsubscribeLogs = logger.LogEntries.Where(entry => entry.EventId.Name == "RemovingZapierUrls").ToList(); + Assert.NotEmpty(unsubscribeLogs); + Assert.All(unsubscribeLogs, entry => + { + Assert.DoesNotContain(zapierUrl, entry.Message); + Assert.DoesNotContain(entry.Properties.Values, value => String.Equals(value?.ToString(), zapierUrl, StringComparison.Ordinal)); + }); + } + + [Theory] + [InlineData("api/v2/webhooks/subscribe", "[]")] + [InlineData("api/v2/webhooks/subscribe", "{\"event\":42,\"target_url\":\"https://hooks.zapier.com/hooks/test\"}")] + [InlineData("api/v2/webhooks/subscribe", "{\"event\":\"NewError\",\"target_url\":false}")] + [InlineData("api/v1/projecthook/subscribe", "[]")] + public async Task SubscribeAsync_MalformedShape_ReturnsBadRequest(string path, string json) + { + // Act + var response = await SendRequestAsync(request => request.Post().AsTestOrganizationClientUser() + .BaseUri(_server.BaseAddress).AppendPath(path).Content(json, "application/json").StatusCodeShouldBeBadRequest()); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory] + [InlineData("api/v2/webhooks/unsubscribe", "[]")] + [InlineData("api/v2/webhooks/unsubscribe", "{\"target_url\":42}")] + [InlineData("api/v1/projecthook/unsubscribe", "[]")] + public async Task UnsubscribeAsync_MalformedShape_ReturnsNotFound(string path, string json) + { + // Act + var response = await SendRequestAsync(request => request.Post().BaseUri(_server.BaseAddress) + .AppendPath(path).Content(json, "application/json").StatusCodeShouldBeNotFound()); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData("https://hooks.zapier.com.evil.example/hooks/test")] + [InlineData("https://hooks.zapier.com@evil.example/hooks/test")] + [InlineData("https://hooks.zapier.com:444/hooks/test")] + public async Task SubscribeAsync_LookalikeZapierTarget_ReturnsNotFound(string targetUrl) + { + // Arrange + var subscription = new { @event = WebHook.KnownEventTypes.NewError, target_url = targetUrl }; + + // Act + var response = await SendRequestAsync(request => request.Post().AsTestOrganizationClientUser() + .AppendPath("webhooks/subscribe").Content(subscription).StatusCodeShouldBeNotFound()); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Empty((await _webHookRepository.GetByUrlAsync(targetUrl)).Documents); + } + + [Theory] + [InlineData("https://hooks.zapier.com.evil.example/hooks/test")] + [InlineData("https://hooks.zapier.com@evil.example/hooks/test")] + [InlineData("https://hooks.zapier.com:444/hooks/test")] + public async Task UnsubscribeAsync_LookalikeZapierTarget_ReturnsNotFound(string targetUrl) + { + // Act + var response = await SendRequestAsync(request => request.Post().AppendPath("webhooks/unsubscribe") + .Content(new { target_url = targetUrl }).StatusCodeShouldBeNotFound()); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] diff --git a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs index 8aa22eca18..056a1cb5af 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs @@ -5,6 +5,7 @@ using Exceptionless.Core.Mail; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; using Exceptionless.Web.Api.Handlers; using Exceptionless.Web.Api.Messages; using Exceptionless.Web.Models; @@ -59,6 +60,7 @@ private AuthHandler CreateHandler(Exception repositoryException) GetService(), GetService(), GetService(), + GetService(), GetService(), GetService(), TimeProvider, diff --git a/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs new file mode 100644 index 0000000000..d3b414c007 --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs @@ -0,0 +1,71 @@ +using System.Text; +using Exceptionless.Web.Extensions; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Exceptionless.Tests.Extensions; + +public sealed class HttpExtensionsTests +{ + [Theory] + [InlineData("Basic", "user@example.com", "password")] + [InlineData("bAsIc", "user@example.com", "password")] + [InlineData("Basic ", "user@example.com", "password")] + [InlineData("Basic", " user@example.com ", " password ")] + [InlineData("Basic", "user@example.com", "pässwörd")] + [InlineData("Basic", "user@example.com", "pass:word:with:colons")] + [InlineData("Basic", "api-token", "")] + [InlineData("Basic", "client", "api-token")] + [InlineData("Basic", "api-token", "x-oauth-basic")] + public void GetBasicAuth_ValidCredentials_PreservesUsernameAndPassword(string scheme, string username, string password) + { + // Arrange + var request = new DefaultHttpContext().Request; + string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + request.Headers.Authorization = $"{scheme} {encoded} "; + + // Act + var credentials = request.GetBasicAuth(); + + // Assert + Assert.NotNull(credentials); + Assert.Equal(username, credentials.Username); + Assert.Equal(password, credentials.Password); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Basic")] + [InlineData("Basic ")] + [InlineData("Basic !!!")] + [InlineData("Basic abc")] + [InlineData("Basic Og==")] + [InlineData("Basic OnBhc3N3b3Jk")] + [InlineData("Basic ICA6cGFzc3dvcmQ=")] + [InlineData("Bearer dXNlcjpwYXNzd29yZA==")] + [InlineData("BasicOther dXNlcjpwYXNzd29yZA==")] + public void GetBasicAuth_InvalidHeader_ReturnsNull(string? authorization) + { + // Arrange + var request = new DefaultHttpContext().Request; + request.Headers.Authorization = authorization; + + // Act + var credentials = request.GetBasicAuth(); + + // Assert + Assert.Null(credentials); + } + + [Fact] + public void GetBasicAuth_NullRequest_ThrowsArgumentNullException() + { + // Act + var exception = Assert.Throws(() => HttpExtensions.GetBasicAuth(null!)); + + // Assert + Assert.Equal("request", exception.ParamName); + } +} diff --git a/tests/Exceptionless.Tests/Services/AuthServiceTests.cs b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs new file mode 100644 index 0000000000..71b8301d4a --- /dev/null +++ b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs @@ -0,0 +1,432 @@ +using Exceptionless.Core.Services; +using Foundatio.Caching; +using Xunit; + +namespace Exceptionless.Tests.Services; + +public sealed class AuthServiceTests(ITestOutputHelper output) : TestWithServices(output) +{ + [Fact] + public async Task TryBeginLoginAsync_FiveFailures_SharesLimitAcrossInstancesAndNormalizedEmails() + { + // Arrange + var first = GetService(); + var second = new AuthService(GetService(), TimeProvider); + for (int failure = 0; failure < 5; failure++) + await FailLoginAsync(first, " User@exceptionless.test ", $"192.0.2.{failure}"); + + // Act + var attempt = await second.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.100", TestCancellationToken); + + // Assert + Assert.Null(attempt); + } + + [Fact] + public async Task TryBeginLoginAsync_MissingIpAddress_OnlyLimitsFailingUser() + { + // Arrange + var service = GetService(); + for (int failure = 0; failure < 5; failure++) + await FailLoginAsync(service, "user@exceptionless.test", null); + + // Act + var blocked = await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken); + var otherUsers = await Task.WhenAll(Enumerable.Range(0, 30).Select(user => + service.TryBeginLoginAsync($"other{user}@exceptionless.test", null, TestCancellationToken))); + + // Assert + Assert.Null(blocked); + Assert.All(otherUsers, Assert.NotNull); + } + + [Fact] + public async Task TryBeginLoginAsync_FifteenIpFailures_BlocksOtherUsersOnlyAtThatAddress() + { + // Arrange + var service = GetService(); + for (int failure = 0; failure < 15; failure++) + await FailLoginAsync(service, $"user{failure}@exceptionless.test", "192.0.2.1"); + + // Act + var blocked = await service.TryBeginLoginAsync("another@exceptionless.test", "192.0.2.1", TestCancellationToken); + var allowed = await service.TryBeginLoginAsync("another@exceptionless.test", "192.0.2.2", TestCancellationToken); + + // Assert + Assert.Null(blocked); + Assert.NotNull(allowed); + } + + [Fact] + public async Task RecordLoginSuccessAsync_ConcurrentValidAttempts_AllRemainAllowed() + { + // Arrange + var service = GetService(); + var cache = Assert.IsType(GetService()); + long originalWrites = cache.Writes; + // Begin every request before any completes to reproduce the former reservation limit. + var attempts = await Task.WhenAll(Enumerable.Range(0, 100).Select(request => + service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken))); + + // Act + await Task.WhenAll(attempts.Where(attempt => attempt is not null).Select(attempt => service.RecordLoginSuccessAsync(attempt!))); + var nextAttempt = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.All(attempts, Assert.NotNull); + Assert.NotNull(nextAttempt); + Assert.Equal(originalWrites, cache.Writes); + } + + [Fact] + public async Task RecordLoginFailureAsync_ConcurrentFailures_AreNotLostAcrossInstances() + { + // Arrange + var first = GetService(); + var second = new AuthService(GetService(), TimeProvider); + var attempts = await Task.WhenAll(Enumerable.Range(0, 5).Select(request => + first.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken))); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var failures = attempts.Select(attempt => Task.Run(async () => + { + await start.Task; + Assert.NotNull(attempt); + await second.RecordLoginFailureAsync(attempt); + }, TestCancellationToken)).ToArray(); + + // Act + start.SetResult(); + await Task.WhenAll(failures); + var blocked = await first.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken); + + // Assert + Assert.Null(blocked); + } + + [Fact] + public async Task RecordLoginFailureAsync_ConcurrentIpFailures_BlockSubsequentRequests() + { + // Arrange + var service = GetService(); + var attempts = await Task.WhenAll(Enumerable.Range(0, 100).Select(user => + service.TryBeginLoginAsync($"user{user}@exceptionless.test", "192.0.2.1", TestCancellationToken))); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var failures = attempts.Select(attempt => Task.Run(async () => + { + await start.Task; + Assert.NotNull(attempt); + await service.RecordLoginFailureAsync(attempt); + }, TestCancellationToken)).ToArray(); + + // Act + start.SetResult(); + await Task.WhenAll(failures); + var blocked = await service.TryBeginLoginAsync("another@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.All(attempts, Assert.NotNull); + Assert.Null(blocked); + } + + [Fact] + public async Task RecordLoginSuccessAsync_OnlyClearsObservedUserFailures() + { + // Arrange + var service = GetService(); + await FailLoginAsync(service, "user@exceptionless.test", "192.0.2.1"); + var success = await BeginLoginAsync(service); + for (int failure = 0; failure < 4; failure++) + await FailLoginAsync(service, "user@exceptionless.test", "192.0.2.1"); + + // Act + await service.RecordLoginSuccessAsync(success); + await FailLoginAsync(service, "user@exceptionless.test", "192.0.2.1"); + // Repeated completion must not remove the new failure that reused the cleared slot. + await service.RecordLoginSuccessAsync(success); + var blocked = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.Null(blocked); + } + + [Fact] + public async Task RecordLoginSuccessAsync_ConcurrentFailures_PreservesEveryNewFailure() + { + // Arrange + var service = GetService(); + await FailLoginAsync(service, "user@exceptionless.test", null); + var successes = await Task.WhenAll(Enumerable.Range(0, 20).Select(request => + BeginLoginAsync(service, "user@exceptionless.test", null))); + var failures = await Task.WhenAll(Enumerable.Range(0, 4).Select(request => + BeginLoginAsync(service, "user@exceptionless.test", null))); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completions = successes.Select(attempt => Task.Run(async () => + { + await start.Task; + await service.RecordLoginSuccessAsync(attempt); + }, TestCancellationToken)).Concat(failures.Select(attempt => Task.Run(async () => + { + await start.Task; + await service.RecordLoginFailureAsync(attempt); + }, TestCancellationToken))).ToArray(); + + // Act + start.SetResult(); + await Task.WhenAll(completions); + await FailLoginAsync(service, "user@exceptionless.test", null); + var blocked = await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken); + + // Assert + Assert.Null(blocked); + } + + [Fact] + public async Task RecordLoginSuccessAsync_DoesNotRefundIpFailures() + { + // Arrange + var service = GetService(); + for (int failure = 0; failure < 14; failure++) + await FailLoginAsync(service, $"other{failure}@exceptionless.test", "192.0.2.1"); + var success = await BeginLoginAsync(service); + + // Act + await service.RecordLoginSuccessAsync(success); + await FailLoginAsync(service, "last@exceptionless.test", "192.0.2.1"); + var blocked = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.Null(blocked); + } + + [Fact] + public async Task RecordLoginSuccessAsync_OldWindow_PreservesNewWindowFailures() + { + // Arrange + TimeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 14, 0, TimeSpan.Zero)); + var service = GetService(); + await FailLoginAsync(service, "user@exceptionless.test", "192.0.2.1"); + var success = await BeginLoginAsync(service); + TimeProvider.Advance(TimeSpan.FromMinutes(1)); + for (int failure = 0; failure < 5; failure++) + await FailLoginAsync(service, "user@exceptionless.test", "192.0.2.1"); + + // Act + await service.RecordLoginSuccessAsync(success); + var blocked = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.Null(blocked); + } + + [Fact] + public async Task RecordLoginFailureAsync_OldWindow_DoesNotChargeNewWindow() + { + // Arrange + TimeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 14, 0, TimeSpan.Zero)); + var service = GetService(); + var oldAttempt = await BeginLoginAsync(service); + TimeProvider.Advance(TimeSpan.FromMinutes(1)); + for (int failure = 0; failure < 4; failure++) + await FailLoginAsync(service, "user@exceptionless.test", "192.0.2.1"); + + // Act + await service.RecordLoginFailureAsync(oldAttempt); + var allowed = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.NotNull(allowed); + } + + [Fact] + public async Task TryBeginLoginAsync_AtQuarterHour_StartsFullNewWindow() + { + // Arrange + TimeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 14, 0, TimeSpan.Zero)); + var service = GetService(); + for (int failure = 0; failure < 5; failure++) + await FailLoginAsync(service, "user@exceptionless.test", "192.0.2.1"); + TimeProvider.Advance(TimeSpan.FromMinutes(1)); + + // Act + for (int failure = 0; failure < 5; failure++) + await FailLoginAsync(service, "user@exceptionless.test", "192.0.2.1"); + var blocked = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + TimeProvider.Advance(TimeSpan.FromMinutes(15)); + var allowed = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.Null(blocked); + Assert.NotNull(allowed); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TryBeginLoginAsync_BlockedRetries_DoNotExtendWindow(bool limitByIpAddress) + { + // Arrange + TimeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 1, 0, TimeSpan.Zero)); + var service = GetService(); + int failureLimit = limitByIpAddress ? 15 : 5; + for (int failure = 0; failure < failureLimit; failure++) + { + string emailAddress = limitByIpAddress ? $"other{failure}@exceptionless.test" : "user@exceptionless.test"; + await FailLoginAsync(service, emailAddress, "192.0.2.1"); + } + TimeProvider.Advance(TimeSpan.FromMinutes(13)); + + // Act + var blockedRetries = await Task.WhenAll(Enumerable.Range(0, 20).Select(request => + service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken))); + TimeProvider.Advance(TimeSpan.FromMinutes(1)); + var allowed = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.All(blockedRetries, Assert.Null); + Assert.NotNull(allowed); + } + + [Fact] + public async Task ClearUserLoginAttemptsAsync_Recovery_ClearsUserLimitButPreservesIpLimit() + { + // Arrange + var service = GetService(); + for (int failure = 0; failure < 5; failure++) + await FailLoginAsync(service, "user@exceptionless.test", "192.0.2.1"); + for (int failure = 0; failure < 10; failure++) + await FailLoginAsync(service, $"other{failure}@exceptionless.test", "192.0.2.1"); + + // Act + await service.ClearUserLoginAttemptsAsync("user@exceptionless.test"); + var blocked = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + var allowed = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.2", TestCancellationToken); + + // Assert + Assert.Null(blocked); + Assert.NotNull(allowed); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task TryBeginLoginAsync_InvalidEmailAddress_Throws(string? emailAddress) + { + // Arrange + var service = GetService(); + + // Act + var exception = await Assert.ThrowsAnyAsync(() => service.TryBeginLoginAsync(emailAddress!, null, TestCancellationToken)); + + // Assert + Assert.Equal("emailAddress", exception.ParamName); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task TryBeginLoginAsync_InvalidIpAddress_Throws(string ipAddress) + { + // Arrange + var service = GetService(); + + // Act + var exception = await Assert.ThrowsAnyAsync(() => service.TryBeginLoginAsync("user@exceptionless.test", ipAddress, TestCancellationToken)); + + // Assert + Assert.Equal("ipAddress", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ClearUserLoginAttemptsAsync_InvalidEmailAddress_Throws(string? emailAddress) + { + // Arrange + var service = GetService(); + + // Act + var exception = await Assert.ThrowsAnyAsync(() => service.ClearUserLoginAttemptsAsync(emailAddress!)); + + // Assert + Assert.Equal("emailAddress", exception.ParamName); + } + + [Fact] + public async Task RecordLoginFailureAsync_NullAttempt_Throws() + { + // Arrange + var service = GetService(); + + // Act + var exception = await Assert.ThrowsAsync(() => service.RecordLoginFailureAsync(null!)); + + // Assert + Assert.Equal("attempt", exception.ParamName); + } + + [Fact] + public async Task RecordLoginSuccessAsync_NullAttempt_Throws() + { + // Arrange + var service = GetService(); + + // Act + var exception = await Assert.ThrowsAsync(() => service.RecordLoginSuccessAsync(null!)); + + // Assert + Assert.Equal("attempt", exception.ParamName); + } + + [Fact] + public async Task TryBeginLoginAsync_CanceledRequest_Throws() + { + // Arrange + var service = GetService(); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + await cancellation.CancelAsync(); + + // Act + var exception = await Assert.ThrowsAsync(() => service.TryBeginLoginAsync("user@exceptionless.test", null, cancellation.Token)); + + // Assert + Assert.Equal(cancellation.Token, exception.CancellationToken); + } + + [Fact] + public void Constructor_NullCache_Throws() + { + // Act + var exception = Assert.Throws(() => new AuthService(null!, TimeProvider)); + + // Assert + Assert.Equal("cacheClient", exception.ParamName); + } + + [Fact] + public void Constructor_NullTimeProvider_Throws() + { + // Arrange + var cache = GetService(); + + // Act + var exception = Assert.Throws(() => new AuthService(cache, null!)); + + // Assert + Assert.Equal("timeProvider", exception.ParamName); + } + + private async Task BeginLoginAsync(AuthService service, string emailAddress = "user@exceptionless.test", string? ipAddress = "192.0.2.1") + { + var attempt = await service.TryBeginLoginAsync(emailAddress, ipAddress, TestCancellationToken); + Assert.NotNull(attempt); + return attempt; + } + + private async Task FailLoginAsync(AuthService service, string emailAddress, string? ipAddress) + { + var attempt = await BeginLoginAsync(service, emailAddress, ipAddress); + await service.RecordLoginFailureAsync(attempt); + } +} diff --git a/tests/Exceptionless.Tests/Utility/PublicAddressPolicyTests.cs b/tests/Exceptionless.Tests/Utility/PublicAddressPolicyTests.cs new file mode 100644 index 0000000000..fb87611cde --- /dev/null +++ b/tests/Exceptionless.Tests/Utility/PublicAddressPolicyTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using Exceptionless.Core.Utility; +using Xunit; + +namespace Exceptionless.Tests.Utility; + +public sealed class PublicAddressPolicyTests +{ + [Theory] + [InlineData("8.8.8.8", true)] + [InlineData("1.1.1.1", true)] + [InlineData("2606:4700:4700::1111", true)] + [InlineData("2001:4860:4860::8888", true)] + [InlineData("::ffff:8.8.8.8", true)] + [InlineData("0.0.0.0", false)] + [InlineData("10.0.0.1", false)] + [InlineData("100.64.0.1", false)] + [InlineData("127.0.0.1", false)] + [InlineData("169.254.169.254", false)] + [InlineData("172.16.0.1", false)] + [InlineData("192.168.1.1", false)] + [InlineData("192.0.0.10", false)] + [InlineData("192.0.2.1", false)] + [InlineData("192.88.99.1", false)] + [InlineData("198.18.0.1", false)] + [InlineData("198.51.100.1", false)] + [InlineData("203.0.113.1", false)] + [InlineData("224.0.0.1", false)] + [InlineData("255.255.255.255", false)] + [InlineData("::", false)] + [InlineData("::1", false)] + [InlineData("::ffff:192.168.1.1", false)] + [InlineData("fe80::1", false)] + [InlineData("fc00::1", false)] + [InlineData("ff02::1", false)] + [InlineData("64:ff9b::a00:1", false)] + [InlineData("64:ff9b:1::a00:1", false)] + [InlineData("2002:7f00:1::", false)] + [InlineData("2001:db8::1", false)] + [InlineData("2001::1", false)] + [InlineData("3fff::1", false)] + public void IsPublic_Address_RejectsSpecialUseAndTransitionRanges(string value, bool expected) + { + // Arrange + var address = IPAddress.Parse(value); + + // Act + bool result = PublicAddressPolicy.IsPublic(address); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void IsPublic_NullAddress_ThrowsArgumentNullException() + { + // Act + var exception = Assert.Throws(() => PublicAddressPolicy.IsPublic(null!)); + + // Assert + Assert.Equal("address", exception.ParamName); + } +}