diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index d98b8eebfc..9362f30981 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(); diff --git a/src/Exceptionless.Core/Services/AuthService.cs b/src/Exceptionless.Core/Services/AuthService.cs new file mode 100644 index 0000000000..fc1509674f --- /dev/null +++ b/src/Exceptionless.Core/Services/AuthService.cs @@ -0,0 +1,336 @@ +using System.Runtime.CompilerServices; +using Exceptionless.DateTimeExtensions; +using Foundatio.Caching; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Services; + +/// +/// Coordinates password checks and temporary failures across authentication callers. +/// +public sealed class AuthService +{ + private const int UserFailureLimit = 5; + private const int IpAddressFailureLimit = 15; + private static readonly TimeSpan AttemptWindow = TimeSpan.FromMinutes(15); + private static readonly TimeSpan AdmissionWaitTimeout = TimeSpan.FromSeconds(2); + private static readonly TimeSpan AdmissionRetryDelay = TimeSpan.FromMilliseconds(50); + private static readonly ConditionalWeakTable InMemoryMutationLocks = new(); + private readonly ScopedCacheClient _cache; + private readonly SemaphoreSlim? _inMemoryMutationLock; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + public AuthService(ICacheClient cacheClient, TimeProvider timeProvider, ILogger logger) + { + ArgumentNullException.ThrowIfNull(cacheClient); + ArgumentNullException.ThrowIfNull(timeProvider); + ArgumentNullException.ThrowIfNull(logger); + + _cache = new ScopedCacheClient(cacheClient, "Auth"); + + // Foundatio 13.0.4's in-memory conditional mutations are not atomic. Keep this + // workaround until FoundatioFx/Foundatio#570 is released and verified. Scope + // wrappers must share the underlying cache's lock; Redis uses its own atomic + // operations. This lock never covers repository access or password hashing. + while (cacheClient is ScopedCacheClient scopedCache) + cacheClient = scopedCache.UnscopedCache; + + _inMemoryMutationLock = cacheClient is InMemoryCacheClient ? InMemoryMutationLocks.GetValue(cacheClient, _ => new SemaphoreSlim(1, 1)) : null; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task TryBeginLoginAsync(string emailAddress, string? ipAddress, CancellationToken cancellationToken = default) + => (await TryBeginLoginCoreAsync(emailAddress, ipAddress, cancellationToken)).Attempt; + + /// + /// Allows a short Basic-password burst to wait for pending checks instead of failing immediately. + /// Completed failures still deny admission. Contention retries share a two-second budget, observe + /// cancellation, and never hold a user reservation while waiting for IP capacity. In-flight cache + /// operations remain subject to the provider's own timeout. + /// + public async Task WaitForLoginAsync(string emailAddress, string? ipAddress, CancellationToken cancellationToken = default) + { + long started = _timeProvider.GetTimestamp(); + long lastAttempt = started; + var result = await TryBeginLoginCoreAsync(emailAddress, ipAddress, cancellationToken); + while (result.Attempt is null && result.BlockedCacheKeys is not null) + { + if (!await WaitForAvailableSlotAsync(result.BlockedCacheKeys, started, lastAttempt, cancellationToken)) + return null; + + lastAttempt = _timeProvider.GetTimestamp(); + result = await TryBeginLoginCoreAsync(emailAddress, ipAddress, cancellationToken); + if (_timeProvider.GetElapsedTime(started) >= AdmissionWaitTimeout) + { + if (result.Attempt is not null) + await result.Attempt.DisposeAsync(); + + return null; + } + } + + return result.Attempt; + } + + private async Task WaitForAvailableSlotAsync(string[] cacheKeys, long started, long lastAttempt, CancellationToken cancellationToken) + { + while (_timeProvider.GetElapsedTime(started) < AdmissionWaitTimeout) + { + cancellationToken.ThrowIfCancellationRequested(); + var entries = await _cache.GetAllAsync(cacheKeys); + cancellationToken.ThrowIfCancellationRequested(); + var remaining = AdmissionWaitTimeout - _timeProvider.GetElapsedTime(started); + if (remaining <= TimeSpan.Zero) + return false; + + if (cacheKeys.Any(key => !entries.TryGetValue(key, out var value) || !value.HasValue)) + { + // A rejected write does not guarantee the slot is occupied. Pace retries + // even when reads report space, avoiding a write loop on provider rejection. + var delay = AdmissionRetryDelay - _timeProvider.GetElapsedTime(lastAttempt); + if (delay > TimeSpan.Zero) + await Task.Delay(remaining < delay ? remaining : delay, _timeProvider, cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + return _timeProvider.GetElapsedTime(started) < AdmissionWaitTimeout; + } + + if (!entries.Values.Any(value => value.HasValue && value.Value.StartsWith("pending:", StringComparison.Ordinal))) + return false; + + // Poll reads only while saturated; do not repeatedly probe every occupied slot with writes. + await Task.Delay(remaining < AdmissionRetryDelay ? remaining : AdmissionRetryDelay, _timeProvider, cancellationToken); + } + + return false; + } + + private async Task<(LoginAttempt? Attempt, string[]? BlockedCacheKeys)> TryBeginLoginCoreAsync(string emailAddress, string? ipAddress, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress); + + if (ipAddress is not null) + ArgumentException.ThrowIfNullOrWhiteSpace(ipAddress); + + cancellationToken.ThrowIfCancellationRequested(); + + var expiresUtc = GetWindowExpiration(); + string[] userCacheKeys = GetUserCacheKeys(emailAddress, expiresUtc); + var failures = await _cache.GetAllAsync(userCacheKeys); + var observedFailures = failures.Where(pair => pair.Value.HasValue && pair.Value.Value.StartsWith("failed:", StringComparison.Ordinal)) + .Select(pair => new KeyValuePair(pair.Key, pair.Value.Value)).ToArray(); + string reservation = $"pending:{Guid.NewGuid():N}"; + var reservedCacheKeys = new List(2); + try + { + string? userCacheKey = await ReserveCacheKeyAsync(userCacheKeys, reservation, expiresUtc, cancellationToken); + if (userCacheKey is null) + return (null, userCacheKeys); + + reservedCacheKeys.Add(userCacheKey); + + if (ipAddress is not null) + { + string[] ipCacheKeys = GetIpAddressCacheKeys(ipAddress, expiresUtc); + string? ipAddressCacheKey = await ReserveCacheKeyAsync(ipCacheKeys, reservation, expiresUtc, cancellationToken); + if (ipAddressCacheKey is null) + { + await ReleaseCacheKeysAsync(reservedCacheKeys, reservation); + return (null, ipCacheKeys); + } + + reservedCacheKeys.Add(ipAddressCacheKey); + } + + cancellationToken.ThrowIfCancellationRequested(); + + string[] cacheKeys = reservedCacheKeys.ToArray(); + + return (new LoginAttempt(expiresUtc, cacheKeys, reservation, observedFailures, () => ReleaseCacheKeysAsync(cacheKeys, reservation)), null); + } + catch + { + await ReleaseCacheKeysAsync(reservedCacheKeys, reservation); + + throw; + } + } + + public async Task RecordLoginFailureAsync(LoginAttempt attempt) + { + ArgumentNullException.ThrowIfNull(attempt); + + // Claim the outcome before cache I/O. A failed or ambiguous write must not + // let disposal refund a password that was already checked and found wrong. + if (!attempt.TryComplete()) + return; + + var remaining = attempt.ExpiresUtc - _timeProvider.GetUtcNow().UtcDateTime; + if (remaining <= TimeSpan.Zero) + return; + + // Pending checks and completed failures share the fixed-window admission budget. + // Reservations expire at the boundary even if a check is still running. + await Task.WhenAll(attempt.CacheKeys.Select(cacheKey => ReplaceIfEqualAsync(cacheKey, $"failed:{attempt.Reservation}", attempt.Reservation, remaining))); + } + + public async Task RecordLoginSuccessAsync(LoginAttempt attempt) + { + ArgumentNullException.ThrowIfNull(attempt); + + if (!attempt.TryComplete()) + return; + + await ReleaseCacheKeysAsync(attempt.CacheKeys, attempt.Reservation); + await RemoveFailuresAsync(attempt.ObservedFailures); + } + + public async Task ClearUserLoginAttemptsAsync(string emailAddress) + { + ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress); + + var failures = await _cache.GetAllAsync(GetUserCacheKeys(emailAddress, GetWindowExpiration())); + // Recovery clears completed failures while checks underway retain admission. + await RemoveFailuresAsync(failures.Where(pair => pair.Value.HasValue && pair.Value.Value.StartsWith("failed:", StringComparison.Ordinal)) + .Select(pair => new KeyValuePair(pair.Key, pair.Value.Value))); + } + + /// + /// Atomically reserves the first available cache entry until the captured window expires. + /// + /// The entries belonging to one user's or IP address's admission budget. + /// The unique value used to conditionally release or charge the entry. + /// The expiration captured before reserving either admission budget. + /// Cancellation while acquiring admission, but not while releasing it. + /// The reserved cache key, or when the admission budget is exhausted. + private async Task ReserveCacheKeyAsync(string[] cacheKeys, string reservation, DateTime expiresUtc, CancellationToken cancellationToken) + { + foreach (string cacheKey in cacheKeys) + if (await AddAsync(cacheKey, reservation, expiresUtc, cancellationToken)) + return cacheKey; + + return null; + } + + /// + /// Releases every entry still owned by the reservation. Cleanup failures are logged and remain + /// charged until expiration, so disposal cannot replace an in-flight error or cancellation. + /// + private async Task ReleaseCacheKeysAsync(IEnumerable cacheKeys, string reservation) + { + foreach (string cacheKey in cacheKeys) + { + try + { + await RemoveIfEqualAsync(cacheKey, reservation); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error releasing login admission reservation: {Message}", ex.Message); + } + } + } + + private Task RemoveFailuresAsync(IEnumerable> failures) + => Task.WhenAll(failures.Select(failure => RemoveIfEqualAsync(failure.Key, failure.Value))); + + private Task AddAsync(string cacheKey, string value, DateTime expiresUtc, CancellationToken cancellationToken) + => ExecuteInMemoryMutationAsync(() => _cache.AddAsync(cacheKey, value, expiresUtc), cancellationToken); + + private Task ReplaceIfEqualAsync(string cacheKey, string value, string expected, TimeSpan expiresIn) + { + if (_inMemoryMutationLock is null) + return _cache.ReplaceIfEqualAsync(cacheKey, value, expected, expiresIn); + + return ExecuteInMemoryMutationAsync(async () => + { + var current = await _cache.GetAsync(cacheKey); + return current.HasValue && String.Equals(current.Value, expected, StringComparison.Ordinal) + && await _cache.SetAsync(cacheKey, value, expiresIn); + }); + } + + private Task RemoveIfEqualAsync(string cacheKey, string expected) + { + if (_inMemoryMutationLock is null) + return _cache.RemoveIfEqualAsync(cacheKey, expected); + + return ExecuteInMemoryMutationAsync(async () => + { + var current = await _cache.GetAsync(cacheKey); + return current.HasValue && String.Equals(current.Value, expected, StringComparison.Ordinal) + && await _cache.RemoveAsync(cacheKey); + }); + } + + private async Task ExecuteInMemoryMutationAsync(Func> action, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_inMemoryMutationLock is null) + return await action(); + + await _inMemoryMutationLock.WaitAsync(cancellationToken); + try + { + return await action(); + } + finally + { + _inMemoryMutationLock.Release(); + } + } + + private DateTime GetWindowExpiration() => _timeProvider.GetUtcNow().UtcDateTime.Floor(AttemptWindow).Add(AttemptWindow); + + /// + /// Gets the user admission cache keys for the captured window, normalizing email casing and whitespace. + /// + /// The email identity shared by interactive and Basic password authentication. + /// The captured window expiration, also used by the IP admission budget. + /// The five cache entries sharing the user's fixed-window admission budget. + private static string[] GetUserCacheKeys(string emailAddress, DateTime expiresUtc) + { + string normalizedEmailAddress = emailAddress.Trim().ToLowerInvariant(); + + return Enumerable.Range(0, UserFailureLimit).Select(index => $"user:{normalizedEmailAddress}:attempts:{expiresUtc.Ticks}:{index}").ToArray(); + } + + /// + /// Gets the IP admission cache keys for the same captured window as the user reservation. + /// + /// The client IP address supplied by the authentication caller. + /// The same captured expiration used by the user admission budget. + /// The fifteen cache entries sharing the IP address's fixed-window admission budget. + private static string[] GetIpAddressCacheKeys(string ipAddress, DateTime expiresUtc) + => Enumerable.Range(0, IpAddressFailureLimit).Select(index => $"ip:{ipAddress}:attempts:{expiresUtc.Ticks}:{index}").ToArray(); + + /// + /// Owns one login admission reservation. Only an unfinished check is released by disposal; + /// a known failure remains charged until expiration even when recording its outcome fails. + /// + public sealed class LoginAttempt : IAsyncDisposable + { + private readonly Func _releaseAsync; + private int _completed; + + internal LoginAttempt(DateTime expiresUtc, string[] cacheKeys, string reservation, KeyValuePair[] observedFailures, Func releaseAsync) + { + _releaseAsync = releaseAsync; + ExpiresUtc = expiresUtc; + CacheKeys = cacheKeys; + Reservation = reservation; + ObservedFailures = observedFailures; + } + + internal DateTime ExpiresUtc { get; } + internal string[] CacheKeys { get; } + internal string Reservation { get; } + internal KeyValuePair[] ObservedFailures { get; } + + internal bool TryComplete() => Interlocked.CompareExchange(ref _completed, 1, 0) == 0; + + public ValueTask DisposeAsync() => TryComplete() ? new(_releaseAsync()) : ValueTask.CompletedTask; + } +} diff --git a/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/AuthHandler.cs index e3c73ead83..b02c4325c2 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) - { - logger.LogError("Login denied for {EmailAddress} for the {UserLoginAttempts} time", email, userLoginAttempts); - return Result.Unauthorized("Login denied."); - } - - if (ipLoginAttempts > 15) + string? ipAddress = httpContext.Request.GetClientIpAddress(); + await using var loginAttempt = await authService.TryBeginLoginAsync(email, ipAddress, httpContext.RequestAborted); + if (loginAttempt is null) { - 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(); 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/Security/ApiKeyAuthenticationHandler.cs b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs index 23a4696d84..6dea7c7a46 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,23 @@ 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 - { - 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.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 + { + return await AuthenticatePasswordAsync(authInfo); } } else @@ -181,6 +170,41 @@ protected override async Task HandleForbiddenAsync(AuthenticationProperties prop Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", scope=\"{String.Join(' ', resourceDefinition.RequiredScopes)}\", resource_metadata=\"{GetResourceMetadataUri(resourceDefinition)}\""; } + private async Task AuthenticatePasswordAsync(AuthInfo authInfo) + { + string emailAddress = authInfo.Username.Trim().ToLowerInvariant(); + string? ipAddress = Request.GetClientIpAddress(); + await using var loginAttempt = await _authService.WaitForLoginAsync(emailAddress, ipAddress, Context.RequestAborted); + if (loginAttempt is null) + { + Logger.LogError("Login denied for {EmailAddress}", emailAddress); + return AuthenticateResult.Fail("Login denied."); + } + + User? user; + try + { + user = await _userRepository.GetByEmailAddressAsync(emailAddress); + } + catch (Exception ex) + { + if (ex is not OperationCanceledException || !Context.RequestAborted.IsCancellationRequested) + Logger.LogError(ex, "Error retrieving user during Basic password authentication: {Message}", ex.Message); + + 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)); + } + private async Task AuthenticateOAuthBearerAsync(string token) { if (!OAuthService.IsOAuthTokenFormat(token)) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index 1cc6d7a85f..f22cbc5f3b 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; @@ -73,396 +81,342 @@ private void ConfigureAuthOptions() _authOptions.MicrosoftSecret = "microsoft-client-secret"; } - [Fact] - public async Task CannotSignupWithoutPassword() + [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) { - var problemDetails = await SendRequestAsAsync(r => r + // Arrange + var login = await SendRequestAsAsync(request => request .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "hello", - Email = "test@domain.com", - Password = null! - }) - .StatusCodeShouldBeUnprocessableEntity() - ); + .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))}"); - Assert.NotNull(problemDetails); - Assert.Single(problemDetails.Errors); - Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); + // Act + using var response = await client.SendAsync(request, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Theory] - [InlineData(true, TestDomainLoginProvider.ValidUsername, TestDomainLoginProvider.ValidPassword)] - [InlineData(true, "test1.2@exceptionless.io", TestDomainLoginProvider.ValidPassword)] - [InlineData(false, "test1@exceptionless.io", "Password1$")] - public Task CannotSignupWhenAccountCreationDisabledWithNoTokenAsync(bool enableAdAuth, string email, string password) + [InlineData("Basic !!!not-base64!!!")] + [InlineData("Basic")] + [InlineData("Basic ")] + [InlineData("Basic Og==")] + [InlineData("Basic ICA6cGFzc3dvcmQ=")] + public async Task BasicAuthentication_MalformedHeader_ReturnsUnauthorized(string authorization) { - _authOptions.EnableAccountCreation = false; - _authOptions.EnableActiveDirectoryAuth = enableAdAuth; + // Arrange + using var client = CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "users/me"); + request.Headers.TryAddWithoutValidation("Authorization", authorization); - if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) - { - var provider = new TestDomainLoginProvider(); - email = provider.GetEmailAddressFromUsername(email); - } + // Act + using var response = await client.SendAsync(request, TestCancellationToken); - return SendRequestAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = email, - Password = password, - InviteToken = null - }) - .StatusCodeShouldBeForbidden() - ); + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } - [Theory] - [InlineData(true, TestDomainLoginProvider.ValidUsername, TestDomainLoginProvider.ValidPassword)] - [InlineData(true, "test2.2@exceptionless.io", TestDomainLoginProvider.ValidPassword)] - [InlineData(false, "test2@exceptionless.io", "Password1$")] - public Task CannotSignupWhenAccountCreationDisabledWithInvalidTokenAsync(bool enableAdAuth, string email, string password) + [Fact] + public async Task BasicAuthentication_PasswordContainingColons_ReturnsCurrentUser() { - _authOptions.EnableAccountCreation = false; - _authOptions.EnableActiveDirectoryAuth = enableAdAuth; + // 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()); - if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) - { - var provider = new TestDomainLoginProvider(); - email = provider.GetEmailAddressFromUsername(email); - } + // Act + var response = await SendRequestAsync(request => request + .BasicAuthorization(user.EmailAddress, password).AppendPath("users/me").StatusCodeShouldBeOk()); - return SendRequestAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = email, - Password = password, - InviteToken = StringExtensions.GetNewToken() - }) - .StatusCodeShouldBeForbidden() - ); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Theory] - [InlineData(true, TestDomainLoginProvider.ValidUsername, TestDomainLoginProvider.ValidPassword)] - [InlineData(false, "test3@exceptionless.io", "Password1$")] - public async Task CanSignupWhenAccountCreationDisabledWithValidTokenAsync(bool enableAdAuth, string email, string password) + [InlineData("Basic")] + [InlineData("Basic !!!not-base64!!!")] + [InlineData("Basic Og==")] + public async Task BasicAuthentication_UnparseableCredentials_ReturnsNoResult(string authorization) { - _authOptions.EnableAccountCreation = false; - _authOptions.EnableActiveDirectoryAuth = enableAdAuth; + // 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); - if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) - { - var provider = new TestDomainLoginProvider(); - email = provider.GetEmailAddressFromUsername(email); - } + // Act + var result = await context.AuthenticateAsync(ApiKeyAuthenticationOptions.ApiKeySchema); - var results = await _organizationRepository.GetAllAsync(); - var organization = results.Documents.First(); + // Assert + Assert.True(result.None); + Assert.Null(result.Failure); + Assert.Null(result.Principal); + } - var invite = new Invite + [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 { - Token = StringExtensions.GetNewToken(), - EmailAddress = email.ToLowerInvariant(), - DateAdded = DateTime.UtcNow + EmailAddress = email, + Password = password.ToSaltedHash(salt), + Salt = salt, + FullName = "Basic Throttle User", + Roles = AuthorizationRoles.AllScopes }; - organization.Invites.Add(invite); - organization = await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); - Assert.NotNull(organization.GetInvite(invite.Token)); - - var result = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = email, - Password = password, - InviteToken = invite.Token - }) - .StatusCodeShouldBeOk() - ); + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); - Assert.NotNull(result); - Assert.False(String.IsNullOrEmpty(result.Token)); + for (int attempt = 0; attempt < 5; attempt++) + { + await SendRequestAsync(r => r + .BasicAuthorization(email, "wrong-password") + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + } - var user = await _userRepository.GetByEmailAddressAsync(email); - Assert.NotNull(user); - Assert.Equal("Test", user.FullName); - Assert.Equal(email, user.EmailAddress); - Assert.NotEqual(password, user.Password); - Assert.Contains(user.OrganizationIds, o => String.Equals(o, organization.Id)); + // Act + var response = await SendRequestAsync(r => r + .BasicAuthorization(email, password) + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); - // Assert user is verified due to the invite. - Assert.True(user.IsEmailAddressVerified); - Assert.Null(user.VerifyEmailAddressToken); - Assert.Equal(DateTime.MinValue, user.VerifyEmailAddressTokenExpiration); + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task CanSignupWhenAccountCreationDisabledWithValidTokenAndInvalidAdAccountAsync() + public async Task BasicPasswordLogin_ConcurrentValidRequests_DoNotConsumeFailureQuota() { - _authOptions.EnableAccountCreation = false; - _authOptions.EnableActiveDirectoryAuth = true; + // 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(); - const string email = "test-user1@exceptionless.io"; - const string password = "invalidAccount1"; + // Act + start.SetResult(); + var results = await Task.WhenAll(requests); + using var next = await client.GetAsync("api/v2/users/me", TestCancellationToken); - var organizations = await _organizationRepository.GetAllAsync(); - var organization = organizations.Documents.First(); - var invite = new Invite + // Assert + Assert.Contains(HttpStatusCode.OK, results); + Assert.All(results, statusCode => Assert.True(statusCode is HttpStatusCode.OK or HttpStatusCode.Unauthorized)); + Assert.Equal(HttpStatusCode.OK, next.StatusCode); + } + + [Fact] + public async Task BasicPasswordLogin_FailuresThroughLogin_AreThrottled() + { + // Arrange + for (int attempt = 0; attempt < 5; attempt++) { - Token = StringExtensions.GetNewToken(), - EmailAddress = email.ToLowerInvariant(), - DateAdded = DateTime.UtcNow - }; + await SendRequestAsync(request => request + .Post() + .AppendPath("auth/login") + .Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = "wrong-password" }) + .StatusCodeShouldBeUnauthorized()); + } - organization.Invites.Add(invite); - await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); - Assert.NotNull(organization.GetInvite(invite.Token)); + // Act + var response = await SendRequestAsync(request => request + .BasicAuthorization(SampleDataService.TEST_USER_EMAIL, SampleDataService.TEST_USER_PASSWORD) + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); - await SendRequestAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = email, - Password = password, - InviteToken = invite.Token - }) - .StatusCodeShouldBeUnauthorized() - ); + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task CanSignupWhenAccountCreationEnabledWithNoTokenAsync() + public async Task BasicPasswordLogin_MissingRemoteIpAddress_ReturnsCurrentUser() { - _authOptions.EnableAccountCreation = true; + // 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))); - const string email = "test4@exceptionless.io"; - const string password = "Password1$"; + // Act + using var response = await client.SendAsync(request, TestCancellationToken); - var result = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = email, - Password = password, - InviteToken = null - }) - .StatusCodeShouldBeOk() - ); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } - Assert.NotNull(result); - Assert.False(String.IsNullOrEmpty(result.Token)); + [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()); - var user = await _userRepository.GetByEmailAddressAsync(email); - Assert.NotNull(user); - Assert.Equal("Test", user.FullName); - Assert.Equal(email, user.EmailAddress); - Assert.NotEqual(password, user.Password); - Assert.Empty(user.OrganizationIds); + // Act + var response = await SendRequestAsync(request => request + .BasicAuthorization(user.EmailAddress, "Password1$") + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); - Assert.False(user.IsEmailAddressVerified); - Assert.NotNull(user.VerifyEmailAddressToken); - Assert.NotEqual(DateTime.MinValue, user.VerifyEmailAddressTokenExpiration); + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task CanSignupWhenAccountCreationEnabledWithNoTokenAndValidAdAccountAsync() + public async Task CancelResetPasswordAsync_ValidToken_ClearsResetToken() { - _authOptions.EnableAccountCreation = true; - _authOptions.EnableActiveDirectoryAuth = true; + // Arrange + const string email = "cancel-reset-password@exceptionless.io"; + var user = new User + { + EmailAddress = email, + FullName = "Cancel Reset Password", + Roles = AuthorizationRoles.AllScopes + }; - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); + user.MarkEmailAddressVerified(); + user.CreatePasswordResetToken(TimeProvider); + string token = user.PasswordResetToken!; + await _userRepository.AddAsync(user); - var result = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = email, - Password = TestDomainLoginProvider.ValidPassword, - InviteToken = null - }) - .StatusCodeShouldBeOk() + // Act + using var response = await SendRequestAsync(r => r + .Post() + .AppendPath($"auth/cancel-reset-password/{token}") + .StatusCodeShouldBeOk() ); - Assert.NotNull(result); - Assert.False(String.IsNullOrEmpty(result.Token)); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var updatedUser = await _userRepository.GetByEmailAddressAsync(email); + Assert.NotNull(updatedUser); + Assert.Null(updatedUser.PasswordResetToken); + Assert.Equal(DateTime.MinValue, updatedUser.PasswordResetTokenExpiration); } [Fact] - public Task CanSignupWhenAccountCreationEnabledWithNoTokenAndInvalidAdAccountAsync() + public async Task CancelResetPasswordAsync_WithNonJsonBody_ReturnsUnsupportedMediaType() { - _authOptions.EnableAccountCreation = true; - _authOptions.EnableActiveDirectoryAuth = true; + // Arrange + const string token = "test-token"; - return SendRequestAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = "testuser2@exceptionless.io", - Password = "literallydoesntmatter", - InviteToken = null - }) - .StatusCodeShouldBeUnauthorized() - ); + // Act + using var response = await SendRequestAsync(r => r + .Post() + .AppendPath($"auth/cancel-reset-password/{token}") + .Content("ignored", "text/plain") + .ExpectedStatus(HttpStatusCode.UnsupportedMediaType)); + + // Assert + Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); } [Fact] - public async Task CanSignupWhenAccountCreationEnabledWithValidTokenAsync() + public async Task ChangePasswordAsync_ReusedCurrentPassword_ReturnsValidationErrorAndPreservesToken() { - _authOptions.EnableAccountCreation = true; - - var organizations = await _organizationRepository.GetAllAsync(); - var organization = organizations.Documents.First(); - const string email = "test5@exceptionless.io"; - const string name = "Test"; - const string password = "Password1$"; + // Arrange + const string email = "test6@exceptionless.io"; + const string password = "Test6 password"; + const string salt = "1234567890123456"; + string passwordHash = password.ToSaltedHash(salt); - var invite = new Invite + var user = new User { - Token = StringExtensions.GetNewToken(), - EmailAddress = email.ToLowerInvariant(), - DateAdded = DateTime.UtcNow + EmailAddress = email, + Password = passwordHash, + Salt = salt, + FullName = "User 6", + Roles = AuthorizationRoles.AllScopes }; - organization.Invites.Clear(); - organization.Invites.Add(invite); - await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); - Assert.NotNull(organization.GetInvite(invite.Token)); + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); var result = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = name, - Email = email, - Password = password, - InviteToken = invite.Token - }) - .StatusCodeShouldBeOk() + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = password, + }) + .StatusCodeShouldBeOk() ); Assert.NotNull(result); - Assert.False(String.IsNullOrEmpty(result.Token)); - - await RefreshDataAsync(); - - var user = await _userRepository.GetByEmailAddressAsync(email); - Assert.NotNull(user); - Assert.Equal("Test", user.FullName); - Assert.NotEmpty(user.OrganizationIds); - Assert.NotNull(user.Salt); - Assert.True(user.IsEmailAddressVerified); - Assert.Equal(password.ToSaltedHash(user.Salt), user.Password); - Assert.Contains(organization.Id, user.OrganizationIds); - - organization = await _organizationRepository.GetByIdAsync(organization.Id); - Assert.NotNull(organization); - Assert.Empty(organization.Invites); + Assert.NotEmpty(result.Token); var token = await _tokenRepository.GetByIdAsync(result.Token); Assert.NotNull(token); - Assert.Equal(user.Id, token.UserId); - Assert.Equal(TokenType.Authentication, token.Type); - - var mailQueue = GetService>() as InMemoryQueue; - Assert.NotNull(mailQueue); - Assert.Equal(0, (await mailQueue.GetQueueStatsAsync()).Enqueued); - } - - [Fact] - public async Task CanSignupWhenAccountCreationEnabledWithValidTokenAndValidAdAccountAsync() - { - _authOptions.EnableAccountCreation = true; - _authOptions.EnableActiveDirectoryAuth = true; - - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); - var results = await _organizationRepository.GetAllAsync(); - var organization = results.Documents.First(); - var invite = new Invite - { - Token = StringExtensions.GetNewToken(), - EmailAddress = email.ToLowerInvariant(), - DateAdded = DateTime.UtcNow - }; - organization.Invites.Add(invite); - await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); - Assert.NotNull(organization.GetInvite(invite.Token)); + Assert.NotNull(token.UserId); + var actualUser = await _userRepository.GetByIdAsync(token.UserId); + Assert.NotNull(actualUser); + Assert.Equal(email, actualUser.EmailAddress); - var result = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = email, - Password = TestDomainLoginProvider.ValidPassword, - InviteToken = invite.Token - }) - .StatusCodeShouldBeOk() + // Act + var problemDetails = await SendRequestAsAsync(r => r + .Post() + .BasicAuthorization(email, password) + .AppendPath("auth/change-password") + .Content(new ChangePasswordModel + { + CurrentPassword = password, + Password = password + }) + .StatusCodeShouldBeUnprocessableEntity() ); - Assert.NotNull(result); - Assert.False(String.IsNullOrEmpty(result.Token)); - } - - [Fact] - public async Task CanSignupWhenAccountCreationEnabledWithValidTokenAndInvalidAdAccountAsync() - { - _authOptions.EnableAccountCreation = true; - _authOptions.EnableActiveDirectoryAuth = true; - - string email = "test-user4@exceptionless.io"; - var results = await _organizationRepository.GetAllAsync(); - var organization = results.Documents.First(); - var invite = new Invite - { - Token = StringExtensions.GetNewToken(), - EmailAddress = email.ToLowerInvariant(), - DateAdded = DateTime.UtcNow - }; - organization.Invites.Add(invite); - await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); - Assert.NotNull(organization.GetInvite(invite.Token)); + // Assert + Assert.NotNull(problemDetails); + Assert.Single(problemDetails.Errors); + Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); - await SendRequestAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = email, - Password = TestDomainLoginProvider.ValidPassword, - InviteToken = invite.Token - }) - .StatusCodeShouldBeUnauthorized() - ); + Assert.NotNull(await _tokenRepository.GetByIdAsync(result.Token)); } [Fact] - public async Task SignupShouldFailWhenUsingExistingAccountWithNoPasswordOrInvalidPassword() + public async Task ChangePasswordAsync_ValidPassword_RevokesExistingTokens() { + // Arrange const string email = "test6@exceptionless.io"; const string password = "Test6 password"; const string salt = "1234567890123456"; @@ -473,91 +427,298 @@ public async Task SignupShouldFailWhenUsingExistingAccountWithNoPasswordOrInvali EmailAddress = email, Password = passwordHash, Salt = salt, - FullName = "User 6" + FullName = "User 6", + Roles = AuthorizationRoles.AllScopes }; user.MarkEmailAddressVerified(); await _userRepository.AddAsync(user); - var problemDetails = await SendRequestAsAsync(r => r + var result = await SendRequestAsAsync(r => r .Post() - .AppendPath("auth/signup") - .Content(new Signup + .AppendPath("auth/login") + .Content(new Login { - Name = "Random Name", Email = email, - Password = null! + Password = password, }) - .StatusCodeShouldBeUnprocessableEntity() + .StatusCodeShouldBeOk() ); - Assert.NotNull(problemDetails); - Assert.Single(problemDetails.Errors); - Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); + Assert.NotNull(result); + Assert.NotEmpty(result.Token); - await SendRequestAsync(r => r + var token = await _tokenRepository.GetByIdAsync(result.Token); + Assert.NotNull(token); + + Assert.NotNull(token.UserId); + var actualUser = await _userRepository.GetByIdAsync(token.UserId); + Assert.NotNull(actualUser); + Assert.Equal(email, actualUser.EmailAddress); + var utcNow = TimeProvider.GetUtcNow().UtcDateTime; + var oauthToken = await _oauthTokenRepository.AddAsync(new OAuthToken + { + Id = ObjectId.GenerateNewId().ToString(), + UserId = actualUser.Id, + ClientId = "test-change-password-client", + GrantId = StringExtensions.GetNewToken(), + Resource = "http://localhost:7110/mcp", + AccessTokenHash = OAuthService.CreateTokenHash("change-password-oauth-access-token"), + RefreshTokenHash = OAuthService.CreateTokenHash("change-password-oauth-refresh-token"), + OrganizationIds = [TestConstants.OrganizationId], + Scopes = [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], + CreatedBy = actualUser.Id, + CreatedUtc = utcNow, + UpdatedUtc = utcNow + }, o => o.ImmediateConsistency()); + + const string newPassword = "NewP@ssword2"; + + // Act + var changePasswordResult = await SendRequestAsAsync(r => r .Post() - .AppendPath("auth/signup") - .Content(new Signup + .BasicAuthorization(email, password) + .AppendPath("auth/change-password") + .Content(new ChangePasswordModel { - Name = "Random Name", - Email = email, - Password = "invalidPass" + CurrentPassword = password, + Password = newPassword }) - .StatusCodeShouldBeUnauthorized() + .StatusCodeShouldBeOk() ); - } - - [Fact] - public async Task FacebookAsync_WithConfiguredProvider_ReturnsToken() - { - // Arrange - const string code = "facebook-user"; - - // Act - var result = await SendExternalLoginAsync("facebook", code); // Assert - await AssertExternalLoginAsync(result, "facebook", code); + Assert.NotNull(changePasswordResult); + Assert.NotEmpty(changePasswordResult.Token); + + Assert.Null(await _tokenRepository.GetByIdAsync(result.Token)); + Assert.Null(await _oauthTokenRepository.GetByIdAsync(oauthToken.Id, o => o.ImmediateConsistency())); + Assert.NotNull(await _tokenRepository.GetByIdAsync(changePasswordResult.Token)); } [Fact] - public async Task GitHubAsync_WithConfiguredProvider_ReturnsToken() + public async Task CheckEmailAddressAsync_ExistingUser_ReturnsCreated() { // Arrange - const string code = "github-user"; + const string email = "existing-email-check@exceptionless.io"; + var user = new User + { + EmailAddress = email, + FullName = "Existing Email Check", + Roles = AuthorizationRoles.AllScopes + }; + + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); // Act - var result = await SendExternalLoginAsync("github", code); + using var response = await SendRequestAsync(r => r + .AppendPath($"auth/check-email-address/{email}") + .StatusCodeShouldBeCreated() + ); // Assert - await AssertExternalLoginAsync(result, "github", code); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); } [Fact] - public async Task GoogleAsync_WithConfiguredProvider_ReturnsToken() + public async Task CheckEmailAddressAsync_MissingUser_ReturnsNoContent() { // Arrange - const string code = "google-user"; + const string email = "missing-email-check@exceptionless.io"; // Act - var result = await SendExternalLoginAsync("google", code); + using var response = await SendRequestAsync(r => r + .AppendPath($"auth/check-email-address/{email}") + .StatusCodeShouldBeNoContent() + ); // Assert - await AssertExternalLoginAsync(result, "google", code); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } [Fact] - public async Task LiveAsync_WithConfiguredProvider_ReturnsToken() + public async Task FacebookAsync_WithConfiguredProvider_ReturnsToken() { // Arrange - const string code = "live-user"; + const string code = "facebook-user"; // Act - var result = await SendExternalLoginAsync("live", code); + var result = await SendExternalLoginAsync("facebook", code); // Assert - await AssertExternalLoginAsync(result, "windowslive", code); + await AssertExternalLoginAsync(result, "facebook", code); + } + + [Fact] + public async Task ForgotPasswordAsync_ExistingUser_CreatesResetToken() + { + // Arrange + const string email = "forgot-password@exceptionless.io"; + var user = new User + { + EmailAddress = email, + FullName = "Forgot Password", + Roles = AuthorizationRoles.AllScopes + }; + + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); + + // Act + using var response = await SendRequestAsync(r => r + .AppendPath($"auth/forgot-password/{email}") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var updatedUser = await _userRepository.GetByEmailAddressAsync(email); + Assert.NotNull(updatedUser); + Assert.False(String.IsNullOrEmpty(updatedUser.PasswordResetToken)); + Assert.True(updatedUser.PasswordResetTokenExpiration.IsAfter(TimeProvider.GetUtcNow().UtcDateTime)); + } + + [Fact] + public async Task ForgotPasswordAsync_UnknownEmail_ReturnsOk() + { + // Arrange + const string email = "missing-password-user@exceptionless.io"; + + // Act + using var response = await SendRequestAsync(r => r + .AppendPath($"auth/forgot-password/{email}") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetIntercomToken_WhenIntercomIsDisabled_ReturnsUnprocessableEntityAsync() + { + // Arrange + _intercomOptions.IntercomSecret = null; + + // Act + var problemDetails = await SendRequestAsAsync(r => r + .BearerToken(TestConstants.UserApiKey) + .AppendPath("auth/intercom") + .StatusCodeShouldBeUnprocessableEntity() + ); + + // Assert + Assert.NotNull(problemDetails); + Assert.True(problemDetails.Errors.TryGetValue("intercom", out string[]? intercomErrors)); + Assert.Contains("Intercom is not enabled.", intercomErrors); + } + + [Fact] + public async Task GetIntercomToken_WhenUnauthenticated_ReturnsUnauthorizedAsync() + { + // Arrange + _intercomOptions.IntercomSecret = "test-intercom-secret-with-adequate-length-12345"; + + // Act + using var response = await SendRequestAsync(r => r + .AppendPath("auth/intercom") + .StatusCodeShouldBeUnauthorized() + ); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetIntercomToken_WithValidAuthenticatedUser_ReturnsJwtAsync() + { + // Arrange + _intercomOptions.IntercomSecret = "test-intercom-secret-with-adequate-length-12345"; + const string email = "intercom-token@exceptionless.io"; + const string password = "Test password"; + const string salt = "1234567890123456"; + var issuedAt = new DateTimeOffset(2026, 3, 19, 12, 0, 0, TimeSpan.Zero); + + TimeProvider.SetUtcNow(issuedAt); + + var user = new User + { + EmailAddress = email, + FullName = "Intercom User", + Password = password.ToSaltedHash(salt), + Roles = AuthorizationRoles.AllScopes, + Salt = salt + }; + + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user, o => o.ImmediateConsistency()); + + var authToken = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = password + }) + .StatusCodeShouldBeOk() + ); + Assert.NotNull(authToken); + + // Act + var intercomToken = await SendRequestAsAsync(r => r + .BearerToken(authToken.Token) + .AppendPath("auth/intercom") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(intercomToken); + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(intercomToken.Token); + Assert.Equal(user.Id, jwt.Payload["user_id"]); + Assert.Equal(issuedAt.UtcDateTime, jwt.Payload.IssuedAt); + Assert.Equal(issuedAt.AddHours(1).ToUnixTimeSeconds(), jwt.Payload.Expiration); + } + + [Fact] + public async Task GitHubAsync_WithConfiguredProvider_ReturnsToken() + { + // Arrange + const string code = "github-user"; + + // Act + var result = await SendExternalLoginAsync("github", code); + + // Assert + await AssertExternalLoginAsync(result, "github", code); + } + + [Fact] + public async Task GitHubAsync_WithInvalidInviteAndAccountCreationDisabled_IsForbidden() + { + // Arrange + _authOptions.EnableAccountCreation = false; + const string code = "github-invited-user"; + string email = TestOAuthProviderClient.GetEmailAddress(code); + + // Act + await SendRequestAsync(r => r + .Post() + .AppendPaths("auth", "github") + .Content(new ExternalAuthInfo + { + ClientId = "client-id", + Code = code, + InviteToken = StringExtensions.GetNewToken(), + RedirectUri = "http://localhost/callback" + }) + .StatusCodeShouldBeForbidden() + ); + + // Assert + Assert.Null(await _userRepository.GetByEmailAddressAsync(email)); } [Fact] @@ -685,215 +846,180 @@ public async Task GitHubAsync_WithValidInviteAndAuthenticatedSession_Authenticat } [Fact] - public async Task GitHubAsync_WithInvalidInviteAndAccountCreationDisabled_IsForbidden() + public async Task GoogleAsync_WithConfiguredProvider_ReturnsToken() { // Arrange - _authOptions.EnableAccountCreation = false; - const string code = "github-invited-user"; - string email = TestOAuthProviderClient.GetEmailAddress(code); + const string code = "google-user"; // Act - await SendRequestAsync(r => r - .Post() - .AppendPaths("auth", "github") - .Content(new ExternalAuthInfo - { - ClientId = "client-id", - Code = code, - InviteToken = StringExtensions.GetNewToken(), - RedirectUri = "http://localhost/callback" - }) - .StatusCodeShouldBeForbidden() - ); + var result = await SendExternalLoginAsync("google", code); // Assert - Assert.Null(await _userRepository.GetByEmailAddressAsync(email)); + await AssertExternalLoginAsync(result, "google", code); } [Fact] - public async Task LoginValidAsync() + public async Task LiveAsync_WithConfiguredProvider_ReturnsToken() { - _authOptions.EnableActiveDirectoryAuth = false; + // Arrange + const string code = "live-user"; - const string email = "test6@exceptionless.io"; - const string password = "Test6 password"; - const string salt = "1234567890123456"; - string passwordHash = password.ToSaltedHash(salt); + // Act + var result = await SendExternalLoginAsync("live", code); + + // Assert + await AssertExternalLoginAsync(result, "windowslive", code); + } + + [Fact] + public async Task LoginAsync_ExistingActiveDirectoryAccountWithValidPassword_ReturnsToken() + { + // Arrange + _authOptions.EnableActiveDirectoryAuth = true; + + var provider = new TestDomainLoginProvider(); + string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); var user = new User { EmailAddress = email, - Password = passwordHash, - Salt = salt, FullName = "User 6" }; user.MarkEmailAddressVerified(); await _userRepository.AddAsync(user); + // Act var result = await SendRequestAsAsync(r => r .Post() .AppendPath("auth/login") .Content(new Login { Email = email, - Password = password + Password = TestDomainLoginProvider.ValidPassword }) .StatusCodeShouldBeOk() ); + // Assert Assert.NotNull(result); Assert.False(String.IsNullOrEmpty(result.Token)); } [Fact] - public async Task RemoveExternalLoginAsync_WithLinkedAccount_RemovesAccount() + public async Task LoginAsync_ExistingActiveDirectoryEmailWithInvalidPassword_ReturnsUnauthorized() { // Arrange - const string providerName = "github"; - const string providerUserId = "github-remove-user"; - var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); - Assert.NotNull(user); - user.AddOAuthAccount(providerName, providerUserId, user.EmailAddress); - await _userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); - - // Act - var result = await SendRequestAsAsync(r => r - .Post() - .AsTestOrganizationUser() - .AppendPaths("auth", "unlink", providerName) - .Content(new ValueFromBody(providerUserId)) - .StatusCodeShouldBeOk() - ); - - // Assert - Assert.NotNull(result); - Assert.False(String.IsNullOrEmpty(result.Token)); - var updatedUser = await _userRepository.GetByIdAsync(user.Id); - Assert.NotNull(updatedUser); - Assert.DoesNotContain(updatedUser.OAuthAccounts, account => account.Provider == providerName && account.ProviderUserId == providerUserId); - } - - [Fact] - public Task RemoveExternalLoginAsync_WithoutProviderUserId_ReturnsBadRequest() - { - // Arrange - var providerUserId = new ValueFromBody(String.Empty); - - // Act & Assert - return SendRequestAsync(r => r - .Post() - .AsTestOrganizationUser() - .AppendPaths("auth", "unlink", "github") - .Content(providerUserId) - .StatusCodeShouldBeBadRequest() - ); - } - - [Fact] - public async Task LoginInvalidPasswordAsync() - { - _authOptions.EnableActiveDirectoryAuth = false; - - const string email = "test7@exceptionless.io"; - const string password = "Test7 password"; - const string salt = "1234567890123456"; - string passwordHash = password.ToSaltedHash(salt); + _authOptions.EnableActiveDirectoryAuth = true; + var provider = new TestDomainLoginProvider(); + string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); var user = new User { EmailAddress = email, - Password = passwordHash, - Salt = salt, - FullName = "User 7" + FullName = "User 6" }; user.MarkEmailAddressVerified(); await _userRepository.AddAsync(user); - await SendRequestAsync(r => r + // Act + using var response = await SendRequestAsync(r => r .Post() .AppendPath("auth/login") .Content(new Login { Email = email, - Password = "This password ain't right" + Password = "Totallywrongpassword1234" }) .StatusCodeShouldBeUnauthorized() ); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task LoginNoSuchUserAsync() + public async Task LoginAsync_ExistingActiveDirectoryUsernameWithInvalidPassword_ReturnsUnauthorized() { - _authOptions.EnableActiveDirectoryAuth = false; + // Arrange + _authOptions.EnableActiveDirectoryAuth = true; - const string email = "test8@exceptionless.io"; - const string password = "Test8 password"; - const string salt = "1234567890123456"; - string passwordHash = password.ToSaltedHash(salt); + var provider = new TestDomainLoginProvider(); + string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); var user = new User { EmailAddress = email, - Password = passwordHash, - Salt = salt, - FullName = "User 8" + FullName = "User 6" }; user.MarkEmailAddressVerified(); await _userRepository.AddAsync(user); - await SendRequestAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = "Thisguydoesntexist@exceptionless.io", - Password = "This password ain't right" - }) - .StatusCodeShouldBeUnauthorized() + // Act + using var response = await SendRequestAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = TestDomainLoginProvider.ValidUsername, + Password = "Totallywrongpassword1234" + }) + .StatusCodeShouldBeUnauthorized() ); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task LoginValidExistingActiveDirectoryAsync() + public async Task LoginAsync_InvalidPassword_ReturnsUnauthorized() { - _authOptions.EnableActiveDirectoryAuth = true; + // Arrange + _authOptions.EnableActiveDirectoryAuth = false; + + const string email = "test7@exceptionless.io"; + const string password = "Test7 password"; + const string salt = "1234567890123456"; + string passwordHash = password.ToSaltedHash(salt); - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); var user = new User { EmailAddress = email, - FullName = "User 6" + Password = passwordHash, + Salt = salt, + FullName = "User 7" }; user.MarkEmailAddressVerified(); await _userRepository.AddAsync(user); - var result = await SendRequestAsAsync(r => r + // Act + using var response = await SendRequestAsync(r => r .Post() .AppendPath("auth/login") .Content(new Login { Email = email, - Password = TestDomainLoginProvider.ValidPassword + Password = "This password ain't right" }) - .StatusCodeShouldBeOk() + .StatusCodeShouldBeUnauthorized() ); - Assert.NotNull(result); - Assert.False(String.IsNullOrEmpty(result.Token)); + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public Task LoginValidNonExistentActiveDirectoryAsync() + public async Task LoginAsync_MissingLocalUserWithValidActiveDirectoryCredentials_ReturnsUnauthorized() { + // Arrange _authOptions.EnableActiveDirectoryAuth = true; var provider = new TestDomainLoginProvider(); string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); - return SendRequestAsync(r => r + // Act + using var response = await SendRequestAsync(r => r .Post() .AppendPath("auth/login") .Content(new Login @@ -903,16 +1029,21 @@ public Task LoginValidNonExistentActiveDirectoryAsync() }) .StatusCodeShouldBeUnauthorized() ); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task LoginInvalidNonExistentActiveDirectoryAsync() + public async Task LoginAsync_NonexistentActiveDirectoryAccount_ReturnsUnauthorizedWithoutCreatingUser() { + // Arrange _authOptions.EnableActiveDirectoryAuth = true; var provider = new TestDomainLoginProvider(); string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); - await SendRequestAsync(r => r + // Act + using var response = await SendRequestAsync(r => r .Post() .AppendPath("auth/login") .Content(new Login @@ -923,72 +1054,95 @@ await SendRequestAsync(r => r .StatusCodeShouldBeUnauthorized() ); + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + // Verify that a user account was not added var user = await _userRepository.GetByEmailAddressAsync($"{email}.au"); Assert.Null(user); } [Fact] - public async Task LoginInvalidExistingActiveDirectoryAsync() + public async Task LoginAsync_UnknownEmail_ReturnsUnauthorized() { - _authOptions.EnableActiveDirectoryAuth = true; + // Arrange + _authOptions.EnableActiveDirectoryAuth = false; - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); + const string email = "test8@exceptionless.io"; + const string password = "Test8 password"; + const string salt = "1234567890123456"; + string passwordHash = password.ToSaltedHash(salt); var user = new User { EmailAddress = email, - FullName = "User 6" + Password = passwordHash, + Salt = salt, + FullName = "User 8" }; user.MarkEmailAddressVerified(); await _userRepository.AddAsync(user); - await SendRequestAsync(r => r + // Act + using var response = await SendRequestAsync(r => r .Post() .AppendPath("auth/login") .Content(new Login { - Email = email, - Password = "Totallywrongpassword1234" + Email = "Thisguydoesntexist@exceptionless.io", + Password = "This password ain't right" }) .StatusCodeShouldBeUnauthorized() ); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task LoginInvalidExistingActiveDirectoryAccountUsingUserNameLoginAsync() + public async Task LoginAsync_ValidPassword_ReturnsToken() { - _authOptions.EnableActiveDirectoryAuth = true; + // Arrange + _authOptions.EnableActiveDirectoryAuth = false; - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); + const string email = "test6@exceptionless.io"; + const string password = "Test6 password"; + const string salt = "1234567890123456"; + string passwordHash = password.ToSaltedHash(salt); var user = new User { EmailAddress = email, + Password = passwordHash, + Salt = salt, FullName = "User 6" }; user.MarkEmailAddressVerified(); await _userRepository.AddAsync(user); - await SendRequestAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = TestDomainLoginProvider.ValidUsername, - Password = "Totallywrongpassword1234" - }) - .StatusCodeShouldBeUnauthorized() + // Act + var result = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = password + }) + .StatusCodeShouldBeOk() ); + + // Assert + Assert.NotNull(result); + Assert.False(String.IsNullOrEmpty(result.Token)); } [Fact] - public async Task CanChangePasswordAsync() + public async Task LogoutAsync_AuthenticationToken_RevokesToken() { - const string email = "test6@exceptionless.io"; - const string password = "Test6 password"; + // Arrange + const string email = "test7@exceptionless.io"; + const string password = "Test7 password"; const string salt = "1234567890123456"; string passwordHash = password.ToSaltedHash(salt); @@ -997,7 +1151,7 @@ public async Task CanChangePasswordAsync() EmailAddress = email, Password = passwordHash, Salt = salt, - FullName = "User 6", + FullName = "User 7", Roles = AuthorizationRoles.AllScopes }; @@ -1016,72 +1170,228 @@ public async Task CanChangePasswordAsync() ); Assert.NotNull(result); - Assert.NotEmpty(result.Token); + // Verify that the token is valid var token = await _tokenRepository.GetByIdAsync(result.Token); Assert.NotNull(token); + Assert.Equal(TokenType.Authentication, token.Type); + Assert.False(token.IsDisabled); + Assert.False(token.IsSuspended); - Assert.NotNull(token.UserId); - var actualUser = await _userRepository.GetByIdAsync(token.UserId); - Assert.NotNull(actualUser); - Assert.Equal(email, actualUser.EmailAddress); - var utcNow = TimeProvider.GetUtcNow().UtcDateTime; - var oauthToken = await _oauthTokenRepository.AddAsync(new OAuthToken - { - Id = ObjectId.GenerateNewId().ToString(), - UserId = actualUser.Id, - ClientId = "test-change-password-client", - GrantId = StringExtensions.GetNewToken(), - Resource = "http://localhost:7110/mcp", - AccessTokenHash = OAuthService.CreateTokenHash("change-password-oauth-access-token"), - RefreshTokenHash = OAuthService.CreateTokenHash("change-password-oauth-refresh-token"), - OrganizationIds = [TestConstants.OrganizationId], - Scopes = [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], - CreatedBy = actualUser.Id, - CreatedUtc = utcNow, - UpdatedUtc = utcNow - }, o => o.ImmediateConsistency()); - - const string newPassword = "NewP@ssword2"; - var changePasswordResult = await SendRequestAsAsync(r => r - .Post() - .BasicAuthorization(email, password) - .AppendPath("auth/change-password") - .Content(new ChangePasswordModel - { - CurrentPassword = password, - Password = newPassword - }) + // Act + using var response = await SendRequestAsync(r => r + .BearerToken(result.Token) + .AppendPath("auth/logout") .StatusCodeShouldBeOk() ); - Assert.NotNull(changePasswordResult); - Assert.NotEmpty(changePasswordResult.Token); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Null(await _tokenRepository.GetByIdAsync(result.Token)); - Assert.Null(await _oauthTokenRepository.GetByIdAsync(oauthToken.Id, o => o.ImmediateConsistency())); - Assert.NotNull(await _tokenRepository.GetByIdAsync(changePasswordResult.Token)); + token = await _tokenRepository.GetByIdAsync(result.Token); + Assert.Null(token); } [Fact] - public async Task ChangePasswordShouldFailWithCurrentPasswordAsync() + public async Task LogoutAsync_ClientAccessToken_ReturnsForbiddenAndPreservesToken() { - const string email = "test6@exceptionless.io"; - const string password = "Test6 password"; - const string salt = "1234567890123456"; - string passwordHash = password.ToSaltedHash(salt); + // Arrange + var token = await _tokenRepository.GetByIdAsync(TestConstants.ApiKey); + Assert.NotNull(token); + Assert.Equal(TokenType.Access, token.Type); + Assert.False(token.IsDisabled); + Assert.False(token.IsSuspended); - var user = new User - { - EmailAddress = email, - Password = passwordHash, - Salt = salt, - FullName = "User 6", - Roles = AuthorizationRoles.AllScopes - }; + // Act + using var response = await SendRequestAsync(r => r + .BearerToken(token.Id) + .AppendPath("auth/logout") + .StatusCodeShouldBeForbidden() + ); - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user); + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + token = (await _tokenRepository.GetByIdAsync(token.Id))!; + Assert.NotNull(token); + Assert.Equal(TokenType.Access, token.Type); + Assert.False(token.IsDisabled); + Assert.False(token.IsSuspended); + } + + [Fact] + public async Task LogoutAsync_UserAccessToken_ReturnsForbiddenAndPreservesToken() + { + // Arrange + var token = await _tokenRepository.GetByIdAsync(TestConstants.UserApiKey); + Assert.NotNull(token); + Assert.Equal(TokenType.Access, token.Type); + Assert.False(token.IsDisabled); + Assert.False(token.IsSuspended); + + // Act + using var response = await SendRequestAsync(r => r + .BearerToken(token.Id) + .AppendPath("auth/logout") + .StatusCodeShouldBeForbidden() + ); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + token = (await _tokenRepository.GetByIdAsync(token.Id))!; + Assert.NotNull(token); + Assert.Equal(TokenType.Access, token.Type); + Assert.False(token.IsDisabled); + Assert.False(token.IsSuspended); + } + + [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 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); + } + + [Fact] + public async Task RemoveExternalLoginAsync_WithLinkedAccount_RemovesAccount() + { + // Arrange + const string providerName = "github"; + const string providerUserId = "github-remove-user"; + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + user.AddOAuthAccount(providerName, providerUserId, user.EmailAddress); + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); + + // Act + var result = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("auth", "unlink", providerName) + .Content(new ValueFromBody(providerUserId)) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(result); + Assert.False(String.IsNullOrEmpty(result.Token)); + var updatedUser = await _userRepository.GetByIdAsync(user.Id); + Assert.NotNull(updatedUser); + Assert.DoesNotContain(updatedUser.OAuthAccounts, account => account.Provider == providerName && account.ProviderUserId == providerUserId); + } + + [Fact] + public async Task RemoveExternalLoginAsync_WithoutProviderUserId_ReturnsBadRequest() + { + // Arrange + var providerUserId = new ValueFromBody(String.Empty); + + // Act + using var response = await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("auth", "unlink", "github") + .Content(providerUserId) + .StatusCodeShouldBeBadRequest() + ); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ResetPasswordAsync_ReusedCurrentPassword_ReturnsValidationErrorAndPreservesToken() + { + // Arrange + const string email = "test6@exceptionless.io"; + const string password = "Test6 password"; + const string salt = "1234567890123456"; + string passwordHash = password.ToSaltedHash(salt); + + var user = new User + { + EmailAddress = email, + Password = passwordHash, + Salt = salt, + FullName = "User 6", + Roles = AuthorizationRoles.AllScopes + }; + + user.MarkEmailAddressVerified(); + user.CreatePasswordResetToken(TimeProvider); + Assert.NotNull(user.PasswordResetToken); + Assert.True(user.PasswordResetTokenExpiration.IsAfter(TimeProvider.GetUtcNow().UtcDateTime)); + + await _userRepository.AddAsync(user); var result = await SendRequestAsAsync(r => r .Post() @@ -1105,18 +1415,20 @@ public async Task ChangePasswordShouldFailWithCurrentPasswordAsync() Assert.NotNull(actualUser); Assert.Equal(email, actualUser.EmailAddress); + // Act var problemDetails = await SendRequestAsAsync(r => r .Post() .BasicAuthorization(email, password) - .AppendPath("auth/change-password") - .Content(new ChangePasswordModel + .AppendPath("auth/reset-password") + .Content(new ResetPasswordModel { - CurrentPassword = password, + PasswordResetToken = user.PasswordResetToken, Password = password }) .StatusCodeShouldBeUnprocessableEntity() ); + // Assert Assert.NotNull(problemDetails); Assert.Single(problemDetails.Errors); Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); @@ -1125,8 +1437,9 @@ public async Task ChangePasswordShouldFailWithCurrentPasswordAsync() } [Fact] - public async Task CanResetPasswordAsync() + public async Task ResetPasswordAsync_ValidResetToken_RevokesExistingTokens() { + // Arrange const string email = "test6@exceptionless.io"; const string password = "Test6 password"; const string salt = "1234567890123456"; @@ -1187,7 +1500,9 @@ public async Task CanResetPasswordAsync() }, o => o.ImmediateConsistency()); const string newPassword = "NewP@ssword2"; - await SendRequestAsync(r => r + + // Act + using var response = await SendRequestAsync(r => r .Post() .BasicAuthorization(email, password) .AppendPath("auth/reset-password") @@ -1199,359 +1514,561 @@ await SendRequestAsync(r => r .StatusCodeShouldBeOk() ); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(await _tokenRepository.GetByIdAsync(result.Token)); Assert.Null(await _oauthTokenRepository.GetByIdAsync(oauthToken.Id, o => o.ImmediateConsistency())); } [Fact] - public async Task ResetPasswordShouldFailWithCurrentPasswordAsync() + public async Task ResetPassword_MissingRemoteIpAddress_ClearsUserLoginAttempts() { - const string email = "test6@exceptionless.io"; - const string password = "Test6 password"; - const string salt = "1234567890123456"; - string passwordHash = password.ToSaltedHash(salt); - - var user = new User + // 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++) { - EmailAddress = email, - Password = passwordHash, - Salt = salt, - FullName = "User 6", - Roles = AuthorizationRoles.AllScopes - }; + await using var loginAttempt = await authService.TryBeginLoginAsync(user.EmailAddress, "192.0.2.1", TestCancellationToken); + Assert.NotNull(loginAttempt); + await authService.RecordLoginFailureAsync(loginAttempt); + } + using var client = _server.CreateClient(); - user.MarkEmailAddressVerified(); + // Act + using var response = await client.PostAsJsonAsync("api/v2/auth/reset-password", + new ResetPasswordModel { PasswordResetToken = user.PasswordResetToken, Password = "Password2$" }, + GetService(), + TestCancellationToken); + await using var loginAttemptAfterReset = await authService.TryBeginLoginAsync(user.EmailAddress, "192.0.2.1", TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(loginAttemptAfterReset); + } + + [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); - Assert.True(user.PasswordResetTokenExpiration.IsAfter(TimeProvider.GetUtcNow().UtcDateTime)); + await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); + using var client = _server.CreateClient(); + const string newPassword = "Password2$"; - await _userRepository.AddAsync(user); + // 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); - var result = await SendRequestAsAsync(r => r + // 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); + } + + [Theory] + [InlineData(true, TestDomainLoginProvider.ValidUsername, TestDomainLoginProvider.ValidPassword)] + [InlineData(true, "test2.2@exceptionless.io", TestDomainLoginProvider.ValidPassword)] + [InlineData(false, "test2@exceptionless.io", "Password1$")] + public async Task SignupAsync_AccountCreationDisabledWithInvalidInvite_ReturnsForbidden(bool enableAdAuth, string email, string password) + { + // Arrange + _authOptions.EnableAccountCreation = false; + _authOptions.EnableActiveDirectoryAuth = enableAdAuth; + + if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) + { + var provider = new TestDomainLoginProvider(); + email = provider.GetEmailAddressFromUsername(email); + } + + // Act + using var response = await SendRequestAsync(r => r .Post() - .AppendPath("auth/login") - .Content(new Login + .AppendPath("auth/signup") + .Content(new Signup { + Name = "Test", Email = email, Password = password, + InviteToken = StringExtensions.GetNewToken() }) - .StatusCodeShouldBeOk() + .StatusCodeShouldBeForbidden() ); - Assert.NotNull(result); - Assert.NotEmpty(result.Token); + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } - var token = await _tokenRepository.GetByIdAsync(result.Token); - Assert.NotNull(token); + [Theory] + [InlineData(true, TestDomainLoginProvider.ValidUsername, TestDomainLoginProvider.ValidPassword)] + [InlineData(true, "test1.2@exceptionless.io", TestDomainLoginProvider.ValidPassword)] + [InlineData(false, "test1@exceptionless.io", "Password1$")] + public async Task SignupAsync_AccountCreationDisabledWithoutInvite_ReturnsForbidden(bool enableAdAuth, string email, string password) + { + // Arrange + _authOptions.EnableAccountCreation = false; + _authOptions.EnableActiveDirectoryAuth = enableAdAuth; - Assert.NotNull(token.UserId); - var actualUser = await _userRepository.GetByIdAsync(token.UserId); - Assert.NotNull(actualUser); - Assert.Equal(email, actualUser.EmailAddress); + if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) + { + var provider = new TestDomainLoginProvider(); + email = provider.GetEmailAddressFromUsername(email); + } - var problemDetails = await SendRequestAsAsync(r => r + // Act + using var response = await SendRequestAsync(r => r .Post() - .BasicAuthorization(email, password) - .AppendPath("auth/reset-password") - .Content(new ResetPasswordModel + .AppendPath("auth/signup") + .Content(new Signup { - PasswordResetToken = user.PasswordResetToken, - Password = password + Name = "Test", + Email = email, + Password = password, + InviteToken = null }) - .StatusCodeShouldBeUnprocessableEntity() + .StatusCodeShouldBeForbidden() ); - Assert.NotNull(problemDetails); - Assert.Single(problemDetails.Errors); - Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); - - Assert.NotNull(await _tokenRepository.GetByIdAsync(result.Token)); + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] - public async Task ForgotPasswordCreatesResetTokenAsync() + public async Task SignupAsync_AccountCreationDisabledWithValidInviteAndInvalidActiveDirectoryAccount_ReturnsUnauthorized() { - const string email = "forgot-password@exceptionless.io"; - var user = new User + // Arrange + _authOptions.EnableAccountCreation = false; + _authOptions.EnableActiveDirectoryAuth = true; + + const string email = "test-user1@exceptionless.io"; + const string password = "invalidAccount1"; + + var organizations = await _organizationRepository.GetAllAsync(); + var organization = organizations.Documents.First(); + var invite = new Invite { - EmailAddress = email, - FullName = "Forgot Password", - Roles = AuthorizationRoles.AllScopes + Token = StringExtensions.GetNewToken(), + EmailAddress = email.ToLowerInvariant(), + DateAdded = DateTime.UtcNow }; - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user); + organization.Invites.Add(invite); + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); + Assert.NotNull(organization.GetInvite(invite.Token)); - await SendRequestAsync(r => r - .AppendPath($"auth/forgot-password/{email}") - .StatusCodeShouldBeOk() + // Act + using var response = await SendRequestAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Test", + Email = email, + Password = password, + InviteToken = invite.Token + }) + .StatusCodeShouldBeUnauthorized() ); - var updatedUser = await _userRepository.GetByEmailAddressAsync(email); - Assert.NotNull(updatedUser); - Assert.False(String.IsNullOrEmpty(updatedUser.PasswordResetToken)); - Assert.True(updatedUser.PasswordResetTokenExpiration.IsAfter(TimeProvider.GetUtcNow().UtcDateTime)); + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [InlineData(true, TestDomainLoginProvider.ValidUsername, TestDomainLoginProvider.ValidPassword)] + [InlineData(false, "test3@exceptionless.io", "Password1$")] + public async Task SignupAsync_AccountCreationDisabledWithValidInvite_CreatesVerifiedUser(bool enableAdAuth, string email, string password) + { + // Arrange + _authOptions.EnableAccountCreation = false; + _authOptions.EnableActiveDirectoryAuth = enableAdAuth; + + if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) + { + var provider = new TestDomainLoginProvider(); + email = provider.GetEmailAddressFromUsername(email); + } + + var results = await _organizationRepository.GetAllAsync(); + var organization = results.Documents.First(); + + var invite = new Invite + { + Token = StringExtensions.GetNewToken(), + EmailAddress = email.ToLowerInvariant(), + DateAdded = DateTime.UtcNow + }; + organization.Invites.Add(invite); + organization = await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); + Assert.NotNull(organization.GetInvite(invite.Token)); + + // Act + var result = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Test", + Email = email, + Password = password, + InviteToken = invite.Token + }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(result); + Assert.False(String.IsNullOrEmpty(result.Token)); + + var user = await _userRepository.GetByEmailAddressAsync(email); + Assert.NotNull(user); + Assert.Equal("Test", user.FullName); + Assert.Equal(email, user.EmailAddress); + Assert.NotEqual(password, user.Password); + Assert.Contains(user.OrganizationIds, o => String.Equals(o, organization.Id)); + + // Assert user is verified due to the invite. + Assert.True(user.IsEmailAddressVerified); + Assert.Null(user.VerifyEmailAddressToken); + Assert.Equal(DateTime.MinValue, user.VerifyEmailAddressTokenExpiration); } [Fact] - public Task ForgotPasswordForUnknownEmailReturnsOkAsync() + public async Task SignupAsync_AccountCreationEnabledWithoutInviteAndInvalidActiveDirectoryAccount_ReturnsUnauthorized() { - return SendRequestAsync(r => r - .AppendPath("auth/forgot-password/missing-password-user@exceptionless.io") - .StatusCodeShouldBeOk() + // Arrange + _authOptions.EnableAccountCreation = true; + _authOptions.EnableActiveDirectoryAuth = true; + + // Act + using var response = await SendRequestAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Test", + Email = "testuser2@exceptionless.io", + Password = "literallydoesntmatter", + InviteToken = null + }) + .StatusCodeShouldBeUnauthorized() ); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task CancelResetPasswordAsync_WithNonJsonBody_ReturnsUnsupportedMediaType() + public async Task SignupAsync_AccountCreationEnabledWithoutInviteAndValidActiveDirectoryAccount_ReturnsToken() { // Arrange - const string token = "test-token"; + _authOptions.EnableAccountCreation = true; + _authOptions.EnableActiveDirectoryAuth = true; + + var provider = new TestDomainLoginProvider(); + string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); // Act - using var response = await SendRequestAsync(r => r - .Post() - .AppendPath($"auth/cancel-reset-password/{token}") - .Content("ignored", "text/plain") - .ExpectedStatus(HttpStatusCode.UnsupportedMediaType)); + var result = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Test", + Email = email, + Password = TestDomainLoginProvider.ValidPassword, + InviteToken = null + }) + .StatusCodeShouldBeOk() + ); // Assert - Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + Assert.NotNull(result); + Assert.False(String.IsNullOrEmpty(result.Token)); } [Fact] - public async Task CancelResetPasswordClearsTokenAsync() + public async Task SignupAsync_AccountCreationEnabledWithoutInvite_CreatesUnverifiedUser() { - const string email = "cancel-reset-password@exceptionless.io"; - var user = new User + // Arrange + _authOptions.EnableAccountCreation = true; + + const string email = "test4@exceptionless.io"; + const string password = "Password1$"; + + // Act + var result = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Test", + Email = email, + Password = password, + InviteToken = null + }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(result); + Assert.False(String.IsNullOrEmpty(result.Token)); + + var user = await _userRepository.GetByEmailAddressAsync(email); + Assert.NotNull(user); + Assert.Equal("Test", user.FullName); + Assert.Equal(email, user.EmailAddress); + Assert.NotEqual(password, user.Password); + Assert.Empty(user.OrganizationIds); + + Assert.False(user.IsEmailAddressVerified); + Assert.NotNull(user.VerifyEmailAddressToken); + Assert.NotEqual(DateTime.MinValue, user.VerifyEmailAddressTokenExpiration); + } + + [Fact] + public async Task SignupAsync_AccountCreationEnabledWithValidInviteAndInvalidActiveDirectoryAccount_ReturnsUnauthorized() + { + // Arrange + _authOptions.EnableAccountCreation = true; + _authOptions.EnableActiveDirectoryAuth = true; + + string email = "test-user4@exceptionless.io"; + var results = await _organizationRepository.GetAllAsync(); + var organization = results.Documents.First(); + var invite = new Invite { - EmailAddress = email, - FullName = "Cancel Reset Password", - Roles = AuthorizationRoles.AllScopes + Token = StringExtensions.GetNewToken(), + EmailAddress = email.ToLowerInvariant(), + DateAdded = DateTime.UtcNow }; + organization.Invites.Add(invite); + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); + Assert.NotNull(organization.GetInvite(invite.Token)); - user.MarkEmailAddressVerified(); - user.CreatePasswordResetToken(TimeProvider); - string token = user.PasswordResetToken!; - await _userRepository.AddAsync(user); - - await SendRequestAsync(r => r - .Post() - .AppendPath($"auth/cancel-reset-password/{token}") - .StatusCodeShouldBeOk() + // Act + using var response = await SendRequestAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Test", + Email = email, + Password = TestDomainLoginProvider.ValidPassword, + InviteToken = invite.Token + }) + .StatusCodeShouldBeUnauthorized() ); - var updatedUser = await _userRepository.GetByEmailAddressAsync(email); - Assert.NotNull(updatedUser); - Assert.Null(updatedUser.PasswordResetToken); - Assert.Equal(DateTime.MinValue, updatedUser.PasswordResetTokenExpiration); + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task EmailAddressAvailabilityReturnsCreatedForExistingUserAsync() + public async Task SignupAsync_AccountCreationEnabledWithValidInviteAndValidActiveDirectoryAccount_ReturnsToken() { - const string email = "existing-email-check@exceptionless.io"; - var user = new User + // Arrange + _authOptions.EnableAccountCreation = true; + _authOptions.EnableActiveDirectoryAuth = true; + + var provider = new TestDomainLoginProvider(); + string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); + + var results = await _organizationRepository.GetAllAsync(); + var organization = results.Documents.First(); + var invite = new Invite { - EmailAddress = email, - FullName = "Existing Email Check", - Roles = AuthorizationRoles.AllScopes + Token = StringExtensions.GetNewToken(), + EmailAddress = email.ToLowerInvariant(), + DateAdded = DateTime.UtcNow }; + organization.Invites.Add(invite); + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); + Assert.NotNull(organization.GetInvite(invite.Token)); - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user); - - await SendRequestAsync(r => r - .AppendPath($"auth/check-email-address/{email}") - .StatusCodeShouldBeCreated() + // Act + var result = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Test", + Email = email, + Password = TestDomainLoginProvider.ValidPassword, + InviteToken = invite.Token + }) + .StatusCodeShouldBeOk() ); - } - [Fact] - public Task EmailAddressAvailabilityReturnsNoContentForMissingUserAsync() - { - return SendRequestAsync(r => r - .AppendPath("auth/check-email-address/missing-email-check@exceptionless.io") - .StatusCodeShouldBeNoContent() - ); + // Assert + Assert.NotNull(result); + Assert.False(String.IsNullOrEmpty(result.Token)); } [Fact] - public async Task CanLogoutUserAsync() + public async Task SignupAsync_AccountCreationEnabledWithValidInvite_CreatesVerifiedUserAndConsumesInvite() { - const string email = "test7@exceptionless.io"; - const string password = "Test7 password"; - const string salt = "1234567890123456"; - string passwordHash = password.ToSaltedHash(salt); + // Arrange + _authOptions.EnableAccountCreation = true; - var user = new User + var organizations = await _organizationRepository.GetAllAsync(); + var organization = organizations.Documents.First(); + const string email = "test5@exceptionless.io"; + const string name = "Test"; + const string password = "Password1$"; + + var invite = new Invite { - EmailAddress = email, - Password = passwordHash, - Salt = salt, - FullName = "User 7", - Roles = AuthorizationRoles.AllScopes + Token = StringExtensions.GetNewToken(), + EmailAddress = email.ToLowerInvariant(), + DateAdded = DateTime.UtcNow }; - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user); + organization.Invites.Clear(); + organization.Invites.Add(invite); + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); + Assert.NotNull(organization.GetInvite(invite.Token)); + // Act var result = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = email, - Password = password, - }) - .StatusCodeShouldBeOk() + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = name, + Email = email, + Password = password, + InviteToken = invite.Token + }) + .StatusCodeShouldBeOk() ); + // Assert Assert.NotNull(result); + Assert.False(String.IsNullOrEmpty(result.Token)); - // Verify that the token is valid - var token = await _tokenRepository.GetByIdAsync(result.Token); - Assert.NotNull(token); - Assert.Equal(TokenType.Authentication, token.Type); - Assert.False(token.IsDisabled); - Assert.False(token.IsSuspended); + await RefreshDataAsync(); - await SendRequestAsync(r => r - .BearerToken(result.Token) - .AppendPath("auth/logout") - .StatusCodeShouldBeOk() - ); + var user = await _userRepository.GetByEmailAddressAsync(email); + Assert.NotNull(user); + Assert.Equal("Test", user.FullName); + Assert.NotEmpty(user.OrganizationIds); + Assert.NotNull(user.Salt); + Assert.True(user.IsEmailAddressVerified); + Assert.Equal(password.ToSaltedHash(user.Salt), user.Password); + Assert.Contains(organization.Id, user.OrganizationIds); - token = await _tokenRepository.GetByIdAsync(result.Token); - Assert.Null(token); - } + organization = await _organizationRepository.GetByIdAsync(organization.Id); + Assert.NotNull(organization); + Assert.Empty(organization.Invites); - [Fact] - public async Task CanLogoutUserAccessTokenAsync() - { - var token = await _tokenRepository.GetByIdAsync(TestConstants.UserApiKey); + var token = await _tokenRepository.GetByIdAsync(result.Token); Assert.NotNull(token); - Assert.Equal(TokenType.Access, token.Type); - Assert.False(token.IsDisabled); - Assert.False(token.IsSuspended); - - await SendRequestAsync(r => r - .BearerToken(token.Id) - .AppendPath("auth/logout") - .StatusCodeShouldBeForbidden() - ); + Assert.Equal(user.Id, token.UserId); + Assert.Equal(TokenType.Authentication, token.Type); - token = (await _tokenRepository.GetByIdAsync(token.Id))!; - Assert.NotNull(token); - Assert.Equal(TokenType.Access, token.Type); - Assert.False(token.IsDisabled); - Assert.False(token.IsSuspended); + var mailQueue = GetService>() as InMemoryQueue; + Assert.NotNull(mailQueue); + Assert.Equal(0, (await mailQueue.GetQueueStatsAsync()).Enqueued); } - [Fact] - public async Task GetIntercomToken_WithValidAuthenticatedUser_ReturnsJwtAsync() + public async Task SignupAsync_ExistingUserWithMissingOrInvalidPassword_RejectsCredentials() { // Arrange - _intercomOptions.IntercomSecret = "test-intercom-secret-with-adequate-length-12345"; - const string email = "intercom-token@exceptionless.io"; - const string password = "Test password"; + const string email = "test6@exceptionless.io"; + const string password = "Test6 password"; const string salt = "1234567890123456"; - var issuedAt = new DateTimeOffset(2026, 3, 19, 12, 0, 0, TimeSpan.Zero); - - TimeProvider.SetUtcNow(issuedAt); + string passwordHash = password.ToSaltedHash(salt); var user = new User { EmailAddress = email, - FullName = "Intercom User", - Password = password.ToSaltedHash(salt), - Roles = AuthorizationRoles.AllScopes, - Salt = salt + Password = passwordHash, + Salt = salt, + FullName = "User 6" }; user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user, o => o.ImmediateConsistency()); + await _userRepository.AddAsync(user); - var authToken = await SendRequestAsAsync(r => r + // Act + var problemDetails = await SendRequestAsAsync(r => r .Post() - .AppendPath("auth/login") - .Content(new Login + .AppendPath("auth/signup") + .Content(new Signup { + Name = "Random Name", Email = email, - Password = password + Password = null! }) - .StatusCodeShouldBeOk() + .StatusCodeShouldBeUnprocessableEntity() ); - Assert.NotNull(authToken); - // Act - var intercomToken = await SendRequestAsAsync(r => r - .BearerToken(authToken.Token) - .AppendPath("auth/intercom") - .StatusCodeShouldBeOk() + using var invalidPasswordResponse = await SendRequestAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Random Name", + Email = email, + Password = "invalidPass" + }) + .StatusCodeShouldBeUnauthorized() ); // Assert - Assert.NotNull(intercomToken); - var jwt = new JwtSecurityTokenHandler().ReadJwtToken(intercomToken.Token); - Assert.Equal(user.Id, jwt.Payload["user_id"]); - Assert.Equal(issuedAt.UtcDateTime, jwt.Payload.IssuedAt); - Assert.Equal(issuedAt.AddHours(1).ToUnixTimeSeconds(), jwt.Payload.Expiration); - } - - [Fact] - public Task GetIntercomToken_WhenUnauthenticated_ReturnsUnauthorizedAsync() - { - // Arrange - _intercomOptions.IntercomSecret = "test-intercom-secret-with-adequate-length-12345"; - - // Act - return SendRequestAsync(r => r - .AppendPath("auth/intercom") - .StatusCodeShouldBeUnauthorized() - ); + Assert.NotNull(problemDetails); + Assert.Single(problemDetails.Errors); + Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); + Assert.Equal(HttpStatusCode.Unauthorized, invalidPasswordResponse.StatusCode); } [Fact] - public async Task GetIntercomToken_WhenIntercomIsDisabled_ReturnsUnprocessableEntityAsync() + public async Task SignupAsync_MissingPassword_ReturnsValidationError() { // Arrange - _intercomOptions.IntercomSecret = null; + var signup = new Signup + { + Name = "hello", + Email = "test@domain.com", + Password = null! + }; // Act var problemDetails = await SendRequestAsAsync(r => r - .BearerToken(TestConstants.UserApiKey) - .AppendPath("auth/intercom") + .Post() + .AppendPath("auth/signup") + .Content(signup) .StatusCodeShouldBeUnprocessableEntity() ); // Assert Assert.NotNull(problemDetails); - Assert.True(problemDetails.Errors.TryGetValue("intercom", out string[]? intercomErrors)); - Assert.Contains("Intercom is not enabled.", intercomErrors); - } - - [Fact] - public async Task CanLogoutClientAccessTokenAsync() - { - var token = await _tokenRepository.GetByIdAsync(TestConstants.ApiKey); - Assert.NotNull(token); - Assert.Equal(TokenType.Access, token.Type); - Assert.False(token.IsDisabled); - Assert.False(token.IsSuspended); - - await SendRequestAsync(r => r - .BearerToken(token.Id) - .AppendPath("auth/logout") - .StatusCodeShouldBeForbidden() - ); - - token = (await _tokenRepository.GetByIdAsync(token.Id))!; - Assert.NotNull(token); - Assert.Equal(TokenType.Access, token.Type); - Assert.False(token.IsDisabled); - Assert.False(token.IsSuspended); + Assert.Single(problemDetails.Errors); + Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); } private async Task AssertExternalLoginAsync(TokenResult? result, string providerName, string providerUserId) diff --git a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs index 175fe5dd79..8e377aca87 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs @@ -5,6 +5,7 @@ using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Services; using Exceptionless.Core.Validation; using Exceptionless.Web.Api.Handlers; using Exceptionless.Web.Api.Messages; @@ -62,6 +63,7 @@ private AuthHandler CreateHandler(IUserRepository userRepository) 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..68d0c73f8b --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs @@ -0,0 +1,75 @@ +using System.Text; +using Exceptionless.Web.Extensions; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Exceptionless.Tests.Extensions; + +public sealed class HttpExtensionsTests +{ + [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() + { + // Arrange + HttpRequest request = null!; + + // Act + var exception = Record.Exception(() => HttpExtensions.GetBasicAuth(request)); + + // Assert + Assert.Equal("request", Assert.IsType(exception).ParamName); + } + + [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); + } + +} diff --git a/tests/Exceptionless.Tests/Security/BasicPasswordAuthenticationTests.cs b/tests/Exceptionless.Tests/Security/BasicPasswordAuthenticationTests.cs new file mode 100644 index 0000000000..2ef56a0e5c --- /dev/null +++ b/tests/Exceptionless.Tests/Security/BasicPasswordAuthenticationTests.cs @@ -0,0 +1,175 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text; +using System.Text.Encodings.Web; +using Exceptionless.Core; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Configuration; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Services; +using Exceptionless.Core.Validation; +using Exceptionless.Web.Security; +using Foundatio.Caching; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Exceptionless.Tests.Security; + +public sealed class BasicPasswordAuthenticationTests(ITestOutputHelper output) : TestWithServices(output) +{ + private const string EmailAddress = "user@exceptionless.test"; + private const string Password = "Password:1$"; + private const string Salt = "1234567890123456"; + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); + + [Theory] + [InlineData(false, true, 5)] + [InlineData(true, true, 15)] + [InlineData(false, false, 5)] + [InlineData(true, false, 15)] + public async Task AuthenticateAsync_ConcurrentBasicRequests_PreservesValidBurstsAndBoundsFailedChecks(bool differentUsers, bool validPassword, int limit) + { + // Arrange + var clock = new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 12, 1, 0, TimeSpan.Zero)); + using var cache = new InMemoryCacheClient(options => options.TimeProvider(clock)); + var service = new AuthService(cache, clock, NullLogger.Instance); + using var repository = CreateUserRepository(); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + string passwordHash = Password.ToSaltedHash(Salt); + int lookups = 0; + int requestCount = limit + 1; + repository.Lookup = async email => + { + Interlocked.Increment(ref lookups); + await release.Task.WaitAsync(TestTimeout, TestCancellationToken); + return new User + { + Id = "123456789012345678901234", + EmailAddress = email, + FullName = "Admission Test User", + IsEmailAddressVerified = true, + Password = passwordHash, + Salt = Salt, + Roles = new HashSet { AuthorizationRoles.User } + }; + }; + + // Act + var requests = Enumerable.Range(0, requestCount).Select(index => AuthenticateAsync( + differentUsers ? $"user{index}@exceptionless.test" : EmailAddress, + validPassword ? Password : "wrong-password", repository, service, cache, clock, TestCancellationToken)).ToArray(); + int initiallyAdmitted = Volatile.Read(ref lookups); + bool allWaiting = requests.All(request => !request.IsCompleted); + release.TrySetResult(); + await Task.WhenAll(requests.Take(limit)).WaitAsync(TestTimeout, TestCancellationToken); + // One request crosses the actual user/IP admission boundary. Complete the + // admitted checks before advancing the waiter, avoiding scheduler-dependent waves. + clock.Advance(TimeSpan.FromMilliseconds(50)); + var results = await Task.WhenAll(requests).WaitAsync(TestTimeout, TestCancellationToken); + + // Assert + Assert.Equal(limit, initiallyAdmitted); + Assert.True(allWaiting); + Assert.All(results, result => Assert.Equal(validPassword, result.Succeeded)); + Assert.Equal(validPassword ? requestCount : limit, Volatile.Read(ref lookups)); + } + + [Theory] + [InlineData(false, false, 1)] + [InlineData(false, true, 1)] + [InlineData(true, false, 1)] + [InlineData(true, true, 0)] + public async Task AuthenticateAsync_RepositoryException_LogsUnexpectedFailureAndReleasesAdmission(bool cancellationException, bool cancelRequest, int expectedErrors) + { + // Arrange + using var cache = new InMemoryCacheClient(); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + using var logger = new CapturingLoggerFactory(); + var service = new AuthService(cache, System.TimeProvider.System, NullLogger.Instance); + using var repository = CreateUserRepository(); + Exception failure = cancellationException ? new OperationCanceledException(cancellation.Token) : new IOException("Synthetic repository failure."); + repository.Lookup = _ => + { + if (cancelRequest) + cancellation.Cancel(); + + return Task.FromException(failure); + }; + + // Act + var result = await AuthenticateAsync(EmailAddress, Password, repository, service, cache, System.TimeProvider.System, cancellation.Token, logger); + + // Assert + Assert.Same(failure, result.Failure); + Assert.False(result.Succeeded); + Assert.Empty(cache.Keys); + Assert.Equal(expectedErrors, logger.Errors.Count); + Assert.All(logger.Errors, exception => Assert.Same(failure, exception)); + } + + protected override void RegisterServices(IServiceCollection services, AppOptions options) + { + base.RegisterServices(services, options); + services.AddSingleton(options.OAuthServerOptions); + } + + private async Task AuthenticateAsync(string emailAddress, string password, IUserRepository repository, AuthService service, + ICacheClient cache, TimeProvider clock, CancellationToken cancellationToken, ILoggerFactory? logger = null) + { + var handler = new ApiKeyAuthenticationHandler( + GetService(), GetService(), cache, service, repository, + GetService(), GetService(), new TestOptionsMonitor(), clock, + logger ?? NullLoggerFactory.Instance, UrlEncoder.Default); + var context = new DefaultHttpContext + { + RequestServices = GetService(), + RequestAborted = cancellationToken + }; + context.Connection.RemoteIpAddress = IPAddress.Parse("192.0.2.1"); + context.Request.Path = "/api/v2/users/me"; + context.Request.Headers.Authorization = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{emailAddress}:{password}")); + await handler.InitializeAsync(new AuthenticationScheme(ApiKeyAuthenticationOptions.ApiKeySchema, null, typeof(ApiKeyAuthenticationHandler)), context); + return await handler.AuthenticateAsync(); + } + + private TestUserRepository CreateUserRepository() + => new(GetService(), GetService(), GetService()); + + private sealed class CapturingLoggerFactory : ILoggerFactory, ILogger + { + public ConcurrentQueue Errors { get; } = new(); + public void AddProvider(ILoggerProvider provider) { } + public ILogger CreateLogger(string categoryName) => this; + public void Dispose() { } + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (logLevel >= LogLevel.Error) + Errors.Enqueue(exception); + } + } + + private sealed class TestOptionsMonitor : IOptionsMonitor + { + public ApiKeyAuthenticationOptions CurrentValue { get; } = new(); + public ApiKeyAuthenticationOptions Get(string? name) => CurrentValue; + public IDisposable? OnChange(Action listener) => null; + } + + private sealed class TestUserRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options) + : UserRepository(configuration, validator, options), IUserRepository + { + public Func> Lookup { get; set; } = null!; + + Task IUserRepository.GetByEmailAddressAsync(string emailAddress) => Lookup(emailAddress); + } +} diff --git a/tests/Exceptionless.Tests/Services/AuthServiceAdmissionTests.cs b/tests/Exceptionless.Tests/Services/AuthServiceAdmissionTests.cs new file mode 100644 index 0000000000..f04e14e22d --- /dev/null +++ b/tests/Exceptionless.Tests/Services/AuthServiceAdmissionTests.cs @@ -0,0 +1,284 @@ +using Exceptionless.Core.Services; +using Foundatio.Caching; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Exceptionless.Tests.Services; + +public sealed class AuthServiceAdmissionTests +{ + private const string EmailAddress = "user@exceptionless.test"; + private const string IpAddress = "192.0.2.1"; + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken; + + [Fact] + public async Task WaitForLoginAsync_CancelledDuringSaturationRead_PreservesCancellationAndFailures() + { + // Arrange + var clock = CreateTimeProvider(); + using var cache = new AdmissionCacheClient(clock); + var service = CreateService(cache, clock); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + var occupied = await ReserveAsync(service, 5); + Assert.All(occupied, Assert.NotNull); + await Task.WhenAll(occupied.Select(attempt => service.RecordLoginFailureAsync(attempt!))); + int reads = 0; + cache.AfterRead = () => + { + if (++reads == 2) + cancellation.Cancel(); + }; + + // Act + var exception = await Record.ExceptionAsync(async () => + { + await service.WaitForLoginAsync(EmailAddress, IpAddress, cancellation.Token); + }); + cache.AfterRead = null; + await using var denied = await service.TryBeginLoginAsync(EmailAddress, IpAddress, TestCancellationToken); + await DisposeAsync(occupied); + + // Assert + Assert.Equal(cancellation.Token, Assert.IsAssignableFrom(exception).CancellationToken); + Assert.Null(denied); + Assert.Equal(10, cache.Keys.Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WaitForLoginAsync_CompletedFailures_DeniesWithoutWaiting(bool sharedIp) + { + // Arrange + var clock = CreateTimeProvider(); + using var cache = new InMemoryCacheClient(options => options.TimeProvider(clock)); + var service = CreateService(cache, clock); + int limit = sharedIp ? 15 : 5; + for (int index = 0; index < limit; index++) + { + await using var attempt = await service.TryBeginLoginAsync(sharedIp ? $"user{index}@exceptionless.test" : EmailAddress, IpAddress, TestCancellationToken); + Assert.NotNull(attempt); + await service.RecordLoginFailureAsync(attempt); + } + + // Act + var pending = service.WaitForLoginAsync(EmailAddress, IpAddress, TestCancellationToken); + var result = await pending.WaitAsync(TestTimeout, TestCancellationToken); + + // Assert + Assert.Null(result); + Assert.Equal(new DateTimeOffset(2026, 1, 1, 12, 1, 0, TimeSpan.Zero), clock.GetUtcNow()); + } + + [Fact] + public async Task WaitForLoginAsync_PendingChecksComplete_AdmitsWithoutIncreasingCapacity() + { + // Arrange + var clock = CreateTimeProvider(); + using var cache = new InMemoryCacheClient(options => options.TimeProvider(clock)); + var service = CreateService(cache, clock); + var occupied = await ReserveAsync(service, 5); + Assert.All(occupied, Assert.NotNull); + + // Act + var pending = service.WaitForLoginAsync(EmailAddress, IpAddress, TestCancellationToken); + bool waited = !pending.IsCompleted; + await service.RecordLoginSuccessAsync(occupied[0]!); + clock.Advance(TimeSpan.FromMilliseconds(50)); + await using var admitted = await pending.WaitAsync(TestTimeout, TestCancellationToken); + await using var excess = await service.TryBeginLoginAsync(EmailAddress, IpAddress, TestCancellationToken); + await DisposeAsync(occupied); + + // Assert + Assert.True(waited); + Assert.NotNull(admitted); + Assert.Null(excess); + } + + [Fact] + public async Task WaitForLoginAsync_PendingChecksFail_DeniesWithoutAdditionalVerification() + { + // Arrange + var clock = CreateTimeProvider(); + using var cache = new InMemoryCacheClient(options => options.TimeProvider(clock)); + var service = CreateService(cache, clock); + var occupied = await ReserveAsync(service, 5); + Assert.All(occupied, Assert.NotNull); + + // Act + var pending = service.WaitForLoginAsync(EmailAddress, IpAddress, TestCancellationToken); + await Task.WhenAll(occupied.Select(attempt => service.RecordLoginFailureAsync(attempt!))); + clock.Advance(TimeSpan.FromMilliseconds(50)); + var result = await pending.WaitAsync(TestTimeout, TestCancellationToken); + await DisposeAsync(occupied); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WaitForLoginAsync_PendingChecksNeverComplete_StopsAtDeadlineOrCancellation(bool cancel) + { + // Arrange + var clock = CreateTimeProvider(); + using var cache = new InMemoryCacheClient(options => options.TimeProvider(clock)); + var service = CreateService(cache, clock); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + var occupied = await ReserveAsync(service, 5); + Assert.All(occupied, Assert.NotNull); + + // Act + var pending = service.WaitForLoginAsync(EmailAddress, IpAddress, cancellation.Token); + if (cancel) + await cancellation.CancelAsync(); + else + clock.Advance(TimeSpan.FromSeconds(2)); + + AuthService.LoginAttempt? result = null; + var exception = await Record.ExceptionAsync(async () => + { + result = await pending.WaitAsync(TestTimeout, TestCancellationToken); + }); + await using var excess = await service.TryBeginLoginAsync(EmailAddress, IpAddress, TestCancellationToken); + await DisposeAsync(occupied); + var remaining = await ReserveAsync(service, 5); + await DisposeAsync(remaining); + + // Assert + if (cancel) + Assert.Equal(cancellation.Token, Assert.IsAssignableFrom(exception).CancellationToken); + else + Assert.Null(exception); + + Assert.Null(result); + Assert.Null(excess); + Assert.All(remaining, Assert.NotNull); + } + + [Fact] + public async Task WaitForLoginAsync_RejectedWrites_PacesRetriesUntilDeadline() + { + // Arrange + var clock = CreateTimeProvider(); + using var cache = new AdmissionCacheClient(clock); + var service = CreateService(cache, clock); + var releaseRetry = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int writes = 0; + cache.BeforeAdd = async () => + { + // Stop an unpaced implementation at its first retry instead of allowing a busy loop. + if (++writes > 5) + await releaseRetry.Task.WaitAsync(TestTimeout, TestCancellationToken); + + return false; + }; + + // Act + var pending = service.WaitForLoginAsync(EmailAddress, IpAddress, TestCancellationToken); + int writesBeforeAdvance = writes; + clock.Advance(TimeSpan.FromSeconds(2)); + releaseRetry.TrySetResult(); + var result = await pending.WaitAsync(TestTimeout, TestCancellationToken); + + // Assert + Assert.Equal(5, writesBeforeAdvance); + Assert.Null(result); + Assert.Empty(cache.Keys); + } + + [Fact] + public async Task WaitForLoginAsync_RetryCompletesAfterDeadline_ReleasesAdmission() + { + // Arrange + var clock = CreateTimeProvider(); + using var cache = new AdmissionCacheClient(clock); + var service = CreateService(cache, clock); + var occupied = await ReserveAsync(service, 5); + Assert.All(occupied, Assert.NotNull); + + // Act + var pending = service.WaitForLoginAsync(EmailAddress, IpAddress, TestCancellationToken); + await occupied[0]!.DisposeAsync(); + cache.AfterAdd = () => + { + cache.AfterAdd = null; + clock.Advance(TimeSpan.FromSeconds(2)); + }; + clock.Advance(TimeSpan.FromMilliseconds(50)); + await using var result = await pending.WaitAsync(TestTimeout, TestCancellationToken); + int reservedEntries = cache.Keys.Count; + await DisposeAsync(occupied); + + // Assert + Assert.Null(result); + Assert.Equal(8, reservedEntries); + } + + [Fact] + public async Task WaitForLoginAsync_SharedIpIsBusy_ReleasesUserCapacityBeforeWaiting() + { + // Arrange + var clock = CreateTimeProvider(); + using var cache = new InMemoryCacheClient(options => options.TimeProvider(clock)); + var service = CreateService(cache, clock); + var occupied = await Task.WhenAll(Enumerable.Range(0, 15).Select(index => + service.TryBeginLoginAsync($"other{index}@exceptionless.test", IpAddress, TestCancellationToken))); + Assert.All(occupied, Assert.NotNull); + + // Act + var pending = service.WaitForLoginAsync(EmailAddress, IpAddress, TestCancellationToken); + var otherIp = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => + service.TryBeginLoginAsync(EmailAddress, "192.0.2.2", TestCancellationToken))); + await DisposeAsync(otherIp); + await occupied[0]!.DisposeAsync(); + clock.Advance(TimeSpan.FromMilliseconds(50)); + await using var result = await pending.WaitAsync(TestTimeout, TestCancellationToken); + await DisposeAsync(occupied); + + // Assert + Assert.All(otherIp, Assert.NotNull); + Assert.NotNull(result); + } + + private static AuthService CreateService(ICacheClient cache, TimeProvider clock) + => new(cache, clock, NullLogger.Instance); + + private static FakeTimeProvider CreateTimeProvider() + => new(new DateTimeOffset(2026, 1, 1, 12, 1, 0, TimeSpan.Zero)); + + private static Task DisposeAsync(IEnumerable attempts) + => Task.WhenAll(attempts.Where(attempt => attempt is not null).Select(attempt => attempt!.DisposeAsync().AsTask())); + + private static Task ReserveAsync(AuthService service, int count) + => Task.WhenAll(Enumerable.Range(0, count).Select(_ => service.TryBeginLoginAsync(EmailAddress, IpAddress, TestCancellationToken))); + + private sealed class AdmissionCacheClient(TimeProvider clock) : InMemoryCacheClient(options => options.TimeProvider(clock)), ICacheClient + { + public Func>? BeforeAdd { get; set; } + public Action? AfterAdd { get; set; } + public Action? AfterRead { get; set; } + + async Task ICacheClient.AddAsync(string key, T value, TimeSpan? expiresIn) + { + if (BeforeAdd is not null) + return await BeforeAdd(); + + bool added = await base.AddAsync(key, value, expiresIn); + if (added) + AfterAdd?.Invoke(); + + return added; + } + + async Task>> ICacheClient.GetAllAsync(IEnumerable keys) + { + var entries = await base.GetAllAsync(keys); + AfterRead?.Invoke(); + return entries; + } + } +} diff --git a/tests/Exceptionless.Tests/Services/AuthServiceReliabilityTests.cs b/tests/Exceptionless.Tests/Services/AuthServiceReliabilityTests.cs new file mode 100644 index 0000000000..1c09295422 --- /dev/null +++ b/tests/Exceptionless.Tests/Services/AuthServiceReliabilityTests.cs @@ -0,0 +1,268 @@ +using Exceptionless.Core.Services; +using Foundatio.Caching; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Exceptionless.Tests.Services; + +public sealed class AuthServiceReliabilityTests +{ + private const string EmailAddress = "user@exceptionless.test"; + private const string IpAddress = "192.0.2.1"; + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken; + + [Fact] + public async Task DisposeAsync_RepeatedDisposal_PerformsCleanupOnce() + { + // Arrange + var timeProvider = CreateTimeProvider(); + using var cache = new InstrumentedCacheClient(timeProvider); + var service = CreateService(cache, timeProvider); + var attempt = await BeginAsync(service); + + // Act + await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => attempt.DisposeAsync().AsTask())); + + // Assert + Assert.Equal(2, cache.ReadOperations); + Assert.Equal(2, cache.Removals); + Assert.Empty(cache.Keys); + } + + [Theory] + [InlineData(true, false, false)] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + [InlineData(true, false, true)] + [InlineData(false, false, true)] + [InlineData(false, true, true)] + public async Task RecordLoginFailureAsync_FailedOutcomeWrite_RetainsBothBudgets(bool synchronousFailure, bool commitBeforeFailure, bool failUserOnly) + { + // Arrange + var timeProvider = CreateTimeProvider(); + using var cache = new InstrumentedCacheClient(timeProvider) + { + FailOutcomeWrites = true, + SynchronousFailure = synchronousFailure, + CommitBeforeFailure = commitBeforeFailure, + FailUserOnly = failUserOnly + }; + var service = CreateService(cache, timeProvider); + var attempt = await BeginAsync(service); + + // Act + var exception = await Record.ExceptionAsync(async () => + { + await using (attempt) + await service.RecordLoginFailureAsync(attempt); + }); + await attempt.DisposeAsync(); + int cleanupRemovals = cache.Removals; + cache.FailOutcomeWrites = false; + var userAttempts = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => + service.TryBeginLoginAsync(EmailAddress, null, TestCancellationToken))); + var ipAttempts = await Task.WhenAll(Enumerable.Range(0, 15).Select(index => + service.TryBeginLoginAsync($"other{index}@exceptionless.test", IpAddress, TestCancellationToken))); + await DisposeAttemptsAsync(userAttempts.Concat(ipAttempts)); + timeProvider.Advance(TimeSpan.FromMinutes(15)); + var afterExpiration = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => + service.TryBeginLoginAsync(EmailAddress, IpAddress, TestCancellationToken))); + await DisposeAttemptsAsync(afterExpiration); + + // Assert + Assert.Same(cache.Failure, exception); + Assert.Equal(0, cleanupRemovals); + Assert.Equal(4, userAttempts.Count(value => value is not null)); + Assert.Equal(14, ipAttempts.Count(value => value is not null)); + Assert.All(afterExpiration, Assert.NotNull); + } + + [Fact] + public async Task RecordLoginSuccessAsync_AlreadyFailedAttempt_PreservesEarlierFailures() + { + // Arrange + var timeProvider = CreateTimeProvider(); + using var cache = new InstrumentedCacheClient(timeProvider); + var service = CreateService(cache, timeProvider); + await using var previous = await BeginAsync(service); + await service.RecordLoginFailureAsync(previous); + await using var failed = await BeginAsync(service); + await service.RecordLoginFailureAsync(failed); + + // Act + await service.RecordLoginSuccessAsync(failed); + var remaining = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => + service.TryBeginLoginAsync(EmailAddress, null, TestCancellationToken))); + await DisposeAttemptsAsync(remaining); + + // Assert + Assert.Equal(3, remaining.Count(value => value is not null)); + } + + [Fact] + public async Task RecordLoginSuccessAsync_RepeatedCompletion_DoesNotRepeatCacheOperations() + { + // Arrange + var timeProvider = CreateTimeProvider(); + using var cache = new InstrumentedCacheClient(timeProvider); + var service = CreateService(cache, timeProvider); + var attempt = await BeginAsync(service); + + // Act + await service.RecordLoginSuccessAsync(attempt); + await service.RecordLoginSuccessAsync(attempt); + await service.RecordLoginFailureAsync(attempt); + await attempt.DisposeAsync(); + await attempt.DisposeAsync(); + + // Assert + Assert.Equal(2, cache.ReadOperations); + Assert.Equal(2, cache.Removals); + Assert.Empty(cache.Keys); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RecordLoginSuccessAsync_SharedCacheWrappers_SerializesObservedRemovalAndNewAdmission(bool nestedScope) + { + // Arrange + var timeProvider = CreateTimeProvider(); + using var cache = new InstrumentedCacheClient(timeProvider); + ICacheClient firstCache = new ScopedCacheClient(cache, "test"); + ICacheClient secondCache = new ScopedCacheClient(cache, "test"); + if (nestedScope) + { + firstCache = new ScopedCacheClient(firstCache, "nested"); + secondCache = new ScopedCacheClient(secondCache, "nested"); + } + + var first = CreateService(firstCache, timeProvider); + var second = CreateService(secondCache, timeProvider); + await using var failed = await BeginAsync(first); + await first.RecordLoginFailureAsync(failed); + await using var success = await BeginAsync(first); + var observed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var continueRemoval = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + cache.AfterRead = async value => + { + if (!value.StartsWith("failed:", StringComparison.Ordinal)) + return; + + observed.TrySetResult(); + await continueRemoval.Task.WaitAsync(TestTimeout, TestCancellationToken); + }; + + // Act + Task completion = first.RecordLoginSuccessAsync(success); + Task acquisition; + bool admissionWaited; + try + { + await observed.Task.WaitAsync(TestTimeout, TestCancellationToken); + acquisition = second.TryBeginLoginAsync(EmailAddress, null, TestCancellationToken); + admissionWaited = !acquisition.IsCompleted; + } + finally + { + continueRemoval.TrySetResult(); + } + + await completion.WaitAsync(TestTimeout, TestCancellationToken); + await using var concurrent = await acquisition.WaitAsync(TestTimeout, TestCancellationToken); + cache.AfterRead = null; + var remaining = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => + second.TryBeginLoginAsync(EmailAddress, null, TestCancellationToken))); + await DisposeAttemptsAsync(remaining); + + // Assert + Assert.True(admissionWaited); + Assert.NotNull(concurrent); + Assert.Equal(4, remaining.Count(value => value is not null)); + Assert.Equal(0, cache.NativeConditionalMutations); + } + + private static async Task BeginAsync(AuthService service) + { + var attempt = await service.TryBeginLoginAsync(EmailAddress, IpAddress, TestCancellationToken); + Assert.NotNull(attempt); + return attempt; + } + + private static AuthService CreateService(ICacheClient cache, TimeProvider timeProvider) + => new(cache, timeProvider, NullLogger.Instance); + + private static FakeTimeProvider CreateTimeProvider() + => new(new DateTimeOffset(2026, 1, 1, 12, 1, 0, TimeSpan.Zero)); + + private static Task DisposeAttemptsAsync(IEnumerable attempts) + => Task.WhenAll(attempts.Where(attempt => attempt is not null).Select(attempt => attempt!.DisposeAsync().AsTask())); + + private sealed class InstrumentedCacheClient(TimeProvider timeProvider) : InMemoryCacheClient(options => options.TimeProvider(timeProvider)), ICacheClient + { + private int _reads; + private int _removals; + private int _nativeConditionalMutations; + + public IOException Failure { get; } = new("Synthetic outcome write failure."); + public bool FailOutcomeWrites { get; set; } + public bool SynchronousFailure { get; set; } + public bool CommitBeforeFailure { get; set; } + public bool FailUserOnly { get; set; } + public Func? AfterRead { get; set; } + public int ReadOperations => Volatile.Read(ref _reads); + public int Removals => Volatile.Read(ref _removals); + public int NativeConditionalMutations => Volatile.Read(ref _nativeConditionalMutations); + + async Task> ICacheClient.GetAsync(string cacheKey) + { + Interlocked.Increment(ref _reads); + var result = await base.GetAsync(cacheKey); + if (AfterRead is not null && result.HasValue && result.Value is string value) + await AfterRead(value); + + return result; + } + + Task ICacheClient.RemoveAsync(string cacheKey) + { + Interlocked.Increment(ref _removals); + return base.RemoveAsync(cacheKey); + } + + Task ICacheClient.RemoveIfEqualAsync(string cacheKey, T expected) + { + Interlocked.Increment(ref _nativeConditionalMutations); + return base.RemoveIfEqualAsync(cacheKey, expected); + } + + Task ICacheClient.ReplaceIfEqualAsync(string cacheKey, T value, T expected, TimeSpan? expiresIn) + { + Interlocked.Increment(ref _nativeConditionalMutations); + return base.ReplaceIfEqualAsync(cacheKey, value, expected, expiresIn); + } + + Task ICacheClient.SetAsync(string cacheKey, T value, TimeSpan? expiresIn) + { + bool shouldFail = FailOutcomeWrites && value is string text && text.StartsWith("failed:", StringComparison.Ordinal) + && (!FailUserOnly || cacheKey.Contains("user:", StringComparison.Ordinal)); + if (!shouldFail) + return base.SetAsync(cacheKey, value, expiresIn); + + if (SynchronousFailure) + throw Failure; + + return WriteThenFailAsync(cacheKey, value, expiresIn); + } + + private async Task WriteThenFailAsync(string cacheKey, T value, TimeSpan? expiresIn) + { + if (CommitBeforeFailure) + await base.SetAsync(cacheKey, value, expiresIn); + + throw Failure; + } + } +} diff --git a/tests/Exceptionless.Tests/Services/AuthServiceTests.cs b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs new file mode 100644 index 0000000000..ed6c32e0e9 --- /dev/null +++ b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs @@ -0,0 +1,569 @@ +using Exceptionless.Core.Services; +using Foundatio.Caching; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Exceptionless.Tests.Services; + +public sealed class AuthServiceTests(ITestOutputHelper output) : TestWithServices(output) +{ + [Fact] + public async Task ClearUserLoginAttemptsAsync_Recovery_PreservesIpFailuresAndChecksUnderway() + { + // Arrange + var service = GetService(); + for (int i = 0; i < 4; i++) + await FailAsync(service); + + await using var pending = await BeginAsync(service); + + // Act + await service.ClearUserLoginAttemptsAsync(" User@exceptionless.test "); + var remaining = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken))); + await pending.DisposeAsync(); + await DisposeAttemptsAsync(remaining); + for (int i = 0; i < 11; i++) + await FailAsync(service, $"other{i}@exceptionless.test"); + + var denied = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.Equal(4, remaining.Count(attempt => attempt is not null)); + Assert.Null(denied); + } + + [Fact] + public void Constructor_NullDependency_ThrowsArgumentNullException() + { + // Arrange + var cache = GetService(); + var logger = Log.CreateLogger(); + + // Act + var cacheException = Record.Exception(() => new AuthService(null!, TimeProvider, logger)); + var timeException = Record.Exception(() => new AuthService(cache, null!, logger)); + var loggerException = Record.Exception(() => new AuthService(cache, TimeProvider, null!)); + + // Assert + Assert.Equal("cacheClient", Assert.IsType(cacheException).ParamName); + Assert.Equal("timeProvider", Assert.IsType(timeException).ParamName); + Assert.Equal("logger", Assert.IsType(loggerException).ParamName); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task DisposeAsync_CleanupFailure_PreservesOriginalExceptionAndReleasesOtherCacheKeys(bool cancelled, bool asynchronousCleanupFailure) + { + // Arrange + using var cache = new FaultingCacheClient(TimeProvider); + var logger = new CapturingLogger(); + var service = new AuthService(cache, TimeProvider, logger); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + cancellation.Cancel(); + Exception failure = cancelled ? new OperationCanceledException(cancellation.Token) : new InvalidOperationException("Synthetic request failure."); + var cleanupFailure = new IOException("Synthetic cleanup failure."); + var attemptedCacheKeys = new List(); + cache.BeforeRemove = cacheKey => + { + attemptedCacheKeys.Add(cacheKey); + if (!cacheKey.Contains("user:", StringComparison.Ordinal)) + return null; + + return asynchronousCleanupFailure ? Task.FromException(cleanupFailure) : throw cleanupFailure; + }; + + // Act + var exception = await Record.ExceptionAsync(async () => + { + await using var attempt = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + throw failure; + }); + cache.BeforeRemove = null; + var ipAttempts = await Task.WhenAll(Enumerable.Range(0, 15).Select(index => service.TryBeginLoginAsync($"other{index}@exceptionless.test", "192.0.2.1", TestCancellationToken))); + await DisposeAttemptsAsync(ipAttempts); + + // Assert + Assert.Same(failure, exception); + Assert.Equal(2, attemptedCacheKeys.Count); + Assert.All(ipAttempts, Assert.NotNull); + var entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Error, entry.Level); + Assert.Same(cleanupFailure, entry.Exception); + Assert.Equal($"Error releasing login admission reservation: {cleanupFailure.Message}", entry.Message); + } + + [Fact] + public async Task DisposeAsync_CompletedFailure_RetainsCharge() + { + // Arrange + var service = GetService(); + for (int i = 0; i < 5; i++) + await FailAsync(service); + + // Act + var denied = await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken); + + // Assert + Assert.Null(denied); + } + + [Fact] + public async Task DisposeAsync_InterruptedAttempt_ReleasesBothReservations() + { + // Arrange + var service = GetService(); + + // Act + for (int i = 0; i < 30; i++) + await (await BeginAsync(service)).DisposeAsync(); + + await using var next = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.NotNull(next); + } + + [Fact] + public async Task RecordLoginFailureAsync_NullAttempt_ThrowsArgumentNullException() + { + // Arrange + var service = GetService(); + + // Act + var exception = await Record.ExceptionAsync(() => service.RecordLoginFailureAsync(null!)); + + // Assert + Assert.Equal("attempt", Assert.IsType(exception).ParamName); + } + + [Fact] + public async Task RecordLoginSuccessAsync_ConcurrentFailures_PreservesNewFailuresAndOtherReservations() + { + // Arrange + var service = GetService(); + await FailAsync(service); + await using var success = await BeginAsync(service); + var failures = await Task.WhenAll(Enumerable.Range(0, 3).Select(_ => BeginAsync(service))); + await Task.WhenAll(failures.Select(service.RecordLoginFailureAsync)); + await DisposeAttemptsAsync(failures); + + // Act + await service.RecordLoginSuccessAsync(success); + await FailAsync(service); + await FailAsync(service); + await service.RecordLoginSuccessAsync(success); + var denied = await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken); + + // Assert + Assert.Null(denied); + } + + [Fact] + public async Task RecordLoginSuccessAsync_ConcurrentReservation_PreservesNewReservation() + { + // Arrange + using var cache = new RacyInMemoryCacheClient(TimeProvider); + var service = new AuthService(cache, TimeProvider, Log.CreateLogger()); + await FailAsync(service); + await using var success = await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken); + Assert.NotNull(success); + + // Act + Task recordSuccess = service.RecordLoginSuccessAsync(success); + Task completed = await Task.WhenAny(recordSuccess, cache.MatchingFailureObserved); + var concurrent = await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken); + Assert.NotNull(concurrent); + + if (completed == cache.MatchingFailureObserved) + cache.ContinueStaleRemoval(); + + await recordSuccess; + var remaining = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => + service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken))); + await concurrent.DisposeAsync(); + await DisposeAttemptsAsync(remaining); + + // Assert + Assert.Equal(4, remaining.Count(attempt => attempt is not null)); + } + + [Fact] + public async Task RecordLoginSuccessAsync_NullAttempt_ThrowsArgumentNullException() + { + // Arrange + var service = GetService(); + + // Act + var exception = await Record.ExceptionAsync(() => service.RecordLoginSuccessAsync(null!)); + + // Assert + Assert.Equal("attempt", Assert.IsType(exception).ParamName); + } + + [Fact] + public async Task RecordLoginSuccessAsync_SharedIpAddress_DoesNotRefundOtherUsersFailures() + { + // Arrange + var service = GetService(); + for (int i = 0; i < 14; i++) + await FailAsync(service, $"other{i}@exceptionless.test"); + + await using var success = await BeginAsync(service); + + // Act + await service.RecordLoginSuccessAsync(success); + await FailAsync(service, "last@exceptionless.test"); + var denied = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.Null(denied); + } + + [Fact] + public async Task RecordLoginSuccessAsync_ValidRequests_DoNotConsumeFailureQuota() + { + // Arrange + var service = GetService(); + + // Act + for (int batch = 0; batch < 20; batch++) + { + var attempts = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => BeginAsync(service))); + await Task.WhenAll(attempts.Select(service.RecordLoginSuccessAsync)); + await DisposeAttemptsAsync(attempts); + } + + await using var next = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.NotNull(next); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TryBeginLoginAsync_CancelledAfterReservation_ReleasesBothCacheKeys(bool includeIpAddress) + { + // Arrange + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + using var cache = new FaultingCacheClient(TimeProvider); + string? ipAddress = includeIpAddress ? "192.0.2.1" : null; + cache.AfterAdd = cacheKey => + { + if (cacheKey.Contains(includeIpAddress ? "ip:" : "user:", StringComparison.Ordinal)) + cancellation.Cancel(); + }; + + var service = new AuthService(cache, TimeProvider, Log.CreateLogger()); + + // Act + var exception = await Record.ExceptionAsync(async () => + { + _ = await service.TryBeginLoginAsync("user@exceptionless.test", ipAddress, cancellation.Token); + }); + cache.AfterAdd = null; + var allowed = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => service.TryBeginLoginAsync("user@exceptionless.test", ipAddress, TestCancellationToken))); + var ipAllowed = await Task.WhenAll(Enumerable.Range(0, 10).Select(index => service.TryBeginLoginAsync($"other{index}@exceptionless.test", ipAddress, TestCancellationToken))); + await DisposeAttemptsAsync(allowed.Concat(ipAllowed)); + + // Assert + Assert.IsAssignableFrom(exception); + Assert.All(allowed, Assert.NotNull); + Assert.All(ipAllowed, Assert.NotNull); + } + + [Fact] + public async Task TryBeginLoginAsync_CancelledRequest_Throws() + { + // Arrange + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + await cancellation.CancelAsync(); + var service = GetService(); + + // Act + var exception = await Record.ExceptionAsync(async () => + { + _ = await service.TryBeginLoginAsync("user@example.test", null, cancellation.Token); + }); + + // Assert + Assert.IsType(exception); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task TryBeginLoginAsync_CleanupFailure_PreservesOriginalExceptionAndLogsCleanupFailure(bool cancelled, bool asynchronousCleanupFailure) + { + // Arrange + TimeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 1, 0, TimeSpan.Zero)); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + using var cache = new FaultingCacheClient(TimeProvider); + var failure = new InvalidOperationException("Synthetic acquisition failure."); + cache.BeforeAdd = cacheKey => + { + if (!cancelled && cacheKey.Contains("ip:", StringComparison.Ordinal)) + throw failure; + }; + + cache.AfterAdd = cacheKey => + { + if (cancelled && cacheKey.Contains("ip:", StringComparison.Ordinal)) + cancellation.Cancel(); + }; + + var cleanupFailure = new IOException("Synthetic cleanup failure."); + cache.BeforeRemove = _ => asynchronousCleanupFailure ? Task.FromException(cleanupFailure) : throw cleanupFailure; + var logger = new CapturingLogger(); + var service = new AuthService(cache, TimeProvider, logger); + + // Act + var exception = await Record.ExceptionAsync(async () => + { + _ = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", cancellation.Token); + }); + cache.BeforeAdd = null; + cache.AfterAdd = null; + cache.BeforeRemove = null; + var beforeExpiration = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken))); + await DisposeAttemptsAsync(beforeExpiration); + TimeProvider.Advance(TimeSpan.FromMinutes(15)); + var afterExpiration = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken))); + await DisposeAttemptsAsync(afterExpiration); + + // Assert + if (cancelled) + Assert.Equal(cancellation.Token, Assert.IsType(exception).CancellationToken); + else + Assert.Same(failure, exception); + + Assert.Equal(cancelled ? 2 : 1, logger.Entries.Count); + Assert.All(logger.Entries, entry => + { + Assert.Equal(LogLevel.Error, entry.Level); + Assert.Same(cleanupFailure, entry.Exception); + Assert.Equal($"Error releasing login admission reservation: {cleanupFailure.Message}", entry.Message); + }); + + Assert.Equal(4, beforeExpiration.Count(attempt => attempt is not null)); + Assert.All(afterExpiration, Assert.NotNull); + } + + [Fact] + public async Task TryBeginLoginAsync_ConcurrentInstances_BoundsChecksBeforeFailuresComplete() + { + // Arrange + var first = GetService(); + var second = new AuthService(GetService(), TimeProvider, Log.CreateLogger()); + + // Act + var attempts = await Task.WhenAll(Enumerable.Range(0, 100).Select(index => + (index % 2 == 0 ? first : second).TryBeginLoginAsync(" User@exceptionless.test ", null, TestCancellationToken))); + await Task.WhenAll(attempts.Where(attempt => attempt is not null).Select(attempt => first.RecordLoginFailureAsync(attempt!))); + await DisposeAttemptsAsync(attempts); + var denied = await second.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken); + + // Assert + Assert.Equal(5, attempts.Count(attempt => attempt is not null)); + Assert.Null(denied); + } + + [Fact] + public async Task TryBeginLoginAsync_ConcurrentUsers_BoundsChecksAtSharedIpAddress() + { + // Arrange + var service = GetService(); + + // Act + var attempts = await Task.WhenAll(Enumerable.Range(0, 100).Select(index => + service.TryBeginLoginAsync($"user{index}@exceptionless.test", "192.0.2.1", TestCancellationToken))); + await Task.WhenAll(attempts.Where(attempt => attempt is not null).Select(attempt => service.RecordLoginFailureAsync(attempt!))); + await DisposeAttemptsAsync(attempts); + var denied = await service.TryBeginLoginAsync("other@exceptionless.test", "192.0.2.1", TestCancellationToken); + var allowed = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => + service.TryBeginLoginAsync("other@exceptionless.test", "192.0.2.2", TestCancellationToken))); + await DisposeAttemptsAsync(allowed); + + // Assert + Assert.Equal(15, attempts.Count(attempt => attempt is not null)); + Assert.Null(denied); + Assert.All(allowed, Assert.NotNull); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task TryBeginLoginAsync_InvalidEmail_Throws(string? email) + { + // Arrange + var service = GetService(); + + // Act + var beginException = await Record.ExceptionAsync(async () => + { + _ = await service.TryBeginLoginAsync(email!, null, TestCancellationToken); + }); + var clearException = await Record.ExceptionAsync(() => service.ClearUserLoginAttemptsAsync(email!)); + + // Assert + Assert.IsAssignableFrom(beginException); + Assert.IsAssignableFrom(clearException); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task TryBeginLoginAsync_InvalidIpAddress_Throws(string address) + { + // Arrange + var service = GetService(); + + // Act + var exception = await Record.ExceptionAsync(async () => + { + _ = await service.TryBeginLoginAsync("user@example.test", address, TestCancellationToken); + }); + + // Assert + Assert.IsAssignableFrom(exception); + } + + [Fact] + public async Task TryBeginLoginAsync_IpCacheFailure_ReleasesUserCacheKey() + { + // Arrange + using var cache = new FaultingCacheClient(TimeProvider); + var failure = new InvalidOperationException("Synthetic cache failure."); + cache.BeforeAdd = cacheKey => + { + if (cacheKey.Contains("ip:", StringComparison.Ordinal)) + throw failure; + }; + + var service = new AuthService(cache, TimeProvider, Log.CreateLogger()); + + // Act + var exception = await Record.ExceptionAsync(async () => + { + _ = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + }); + cache.BeforeAdd = null; + var allowed = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken))); + await DisposeAttemptsAsync(allowed); + + // Assert + Assert.Same(failure, exception); + Assert.All(allowed, Assert.NotNull); + } + + [Fact] + public async Task TryBeginLoginAsync_QuarterHour_ExpiresFailuresAndAbandonedReservations() + { + // Arrange + TimeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 14, 0, TimeSpan.Zero)); + var service = GetService(); + await using var old = await BeginAsync(service); + for (int i = 0; i < 4; i++) + await FailAsync(service); + + // Act + var beforeBoundary = await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken); + TimeProvider.Advance(TimeSpan.FromMinutes(1)); + var current = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken))); + await Task.WhenAll(current.Where(attempt => attempt is not null).Select(attempt => service.RecordLoginFailureAsync(attempt!))); + await DisposeAttemptsAsync(current); + await service.RecordLoginSuccessAsync(old); + await service.RecordLoginFailureAsync(old); + await old.DisposeAsync(); + var afterOldCompletion = await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken); + TimeProvider.Advance(TimeSpan.FromMinutes(15)); + await using var next = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.Null(beforeBoundary); + Assert.All(current, Assert.NotNull); + Assert.Null(afterOldCompletion); + Assert.NotNull(next); + } + + private async Task BeginAsync(AuthService service, string email = "user@exceptionless.test") + { + var attempt = await service.TryBeginLoginAsync(email, "192.0.2.1", TestCancellationToken); + Assert.NotNull(attempt); + + return attempt; + } + + private static Task DisposeAttemptsAsync(IEnumerable attempts) + => Task.WhenAll(attempts.Where(attempt => attempt is not null).Select(attempt => attempt!.DisposeAsync().AsTask())); + + private async Task FailAsync(AuthService service, string email = "user@exceptionless.test") + { + await using var attempt = await BeginAsync(service, email); + await service.RecordLoginFailureAsync(attempt); + } + + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => Entries.Add((logLevel, formatter(state, exception), exception)); + } + + private sealed class FaultingCacheClient(TimeProvider timeProvider) : InMemoryCacheClient(options => options.TimeProvider(timeProvider)), ICacheClient + { + public Action? BeforeAdd { get; set; } + public Action? AfterAdd { get; set; } + public Func?>? BeforeRemove { get; set; } + + async Task ICacheClient.AddAsync(string cacheKey, T value, TimeSpan? expiresIn) + { + BeforeAdd?.Invoke(cacheKey); + + bool added = await base.AddAsync(cacheKey, value, expiresIn); + if (added) + AfterAdd?.Invoke(cacheKey); + + return added; + } + + Task ICacheClient.RemoveIfEqualAsync(string cacheKey, T expected) + => BeforeRemove?.Invoke(cacheKey) ?? base.RemoveIfEqualAsync(cacheKey, expected); + + Task ICacheClient.RemoveAsync(string cacheKey) + => BeforeRemove?.Invoke(cacheKey) ?? base.RemoveAsync(cacheKey); + } + + private sealed class RacyInMemoryCacheClient(TimeProvider timeProvider) : InMemoryCacheClient(options => options.TimeProvider(timeProvider)), ICacheClient + { + private readonly TaskCompletionSource _continueStaleRemoval = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _matchingFailureObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task MatchingFailureObserved => _matchingFailureObserved.Task; + + public void ContinueStaleRemoval() => _continueStaleRemoval.TrySetResult(); + + async Task ICacheClient.RemoveIfEqualAsync(string cacheKey, T expected) + { + bool removed = await base.RemoveIfEqualAsync(cacheKey, expected); + if (!removed || expected is not string value || !value.StartsWith("failed:", StringComparison.Ordinal)) + return removed; + + _matchingFailureObserved.TrySetResult(); + await _continueStaleRemoval.Task; + + return await base.RemoveAsync(cacheKey); + } + } +}