From d1d1c998c5c48b530b3fd15efcf5faf2f2fd7751 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 1 Oct 2026 21:57:48 -0500 Subject: [PATCH 1/9] Coordinate password login admission across callers --- src/Exceptionless.Core/Bootstrapper.cs | 1 + .../Services/AuthService.cs | 128 ++++++ .../Api/Handlers/AuthHandler.cs | 44 +-- .../Extensions/HttpExtensions.cs | 20 +- .../Security/ApiKeyAuthenticationHandler.cs | 68 ++-- .../Api/Endpoints/AuthEndpointTests.cs | 367 ++++++++++++++++++ .../Api/Handlers/AuthHandlerTests.cs | 2 + .../Exceptionless.Tests/AppWebHostFactory.cs | 13 +- .../Extensions/HttpExtensionsTests.cs | 71 ++++ .../Services/AuthServiceTests.cs | 178 +++++++++ tests/http/users.http | 8 + 11 files changed, 833 insertions(+), 67 deletions(-) create mode 100644 src/Exceptionless.Core/Services/AuthService.cs create mode 100644 tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs create mode 100644 tests/Exceptionless.Tests/Services/AuthServiceTests.cs 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..696d0fb044 --- /dev/null +++ b/src/Exceptionless.Core/Services/AuthService.cs @@ -0,0 +1,128 @@ +using Exceptionless.DateTimeExtensions; +using Foundatio.Caching; + +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 readonly ScopedCacheClient _cache; + private readonly TimeProvider _timeProvider; + + public AuthService(ICacheClient cacheClient, TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(cacheClient); + ArgumentNullException.ThrowIfNull(timeProvider); + _cache = new ScopedCacheClient(cacheClient, "Auth"); + _timeProvider = timeProvider; + } + + public async Task TryBeginLoginAsync(string emailAddress, string? ipAddress, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress); + if (ipAddress is not null) + ArgumentException.ThrowIfNullOrWhiteSpace(ipAddress); + cancellationToken.ThrowIfCancellationRequested(); + + var expiresUtc = GetWindowExpiration(); + string[] userKeys = GetKeys($"user:{emailAddress.Trim().ToLowerInvariant()}", UserFailureLimit, expiresUtc); + var failures = await _cache.GetAllAsync(userKeys); + 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 keys = new List(2); + try + { + string? userKey = await ReserveAsync(userKeys, reservation, expiresUtc); + if (userKey is null) + return null; + keys.Add(userKey); + if (ipAddress is not null) + { + string? ipKey = await ReserveAsync(GetKeys($"ip:{ipAddress}", IpAddressFailureLimit, expiresUtc), reservation, expiresUtc); + if (ipKey is null) + { + await ReleaseAsync(keys, reservation); + return null; + } + keys.Add(ipKey); + } + cancellationToken.ThrowIfCancellationRequested(); + return new LoginAttempt(this, expiresUtc, keys.ToArray(), reservation, observedFailures); + } + catch + { + await ReleaseAsync(keys, reservation); + throw; + } + } + + public async Task RecordLoginFailureAsync(LoginAttempt attempt) + { + ArgumentNullException.ThrowIfNull(attempt); + var remaining = attempt.ExpiresUtc - _timeProvider.GetUtcNow().UtcDateTime; + if (remaining <= TimeSpan.Zero) + return; + // A crashed worker stays charged until the boundary, so a slow check cannot + // outlive its reservation and silently restore admission. + await Task.WhenAll(attempt.Keys.Select(key => _cache.ReplaceIfEqualAsync(key, $"failed:{attempt.Reservation}", attempt.Reservation, remaining))); + } + + public async Task RecordLoginSuccessAsync(LoginAttempt attempt) + { + ArgumentNullException.ThrowIfNull(attempt); + await ReleaseAsync(attempt.Keys, attempt.Reservation); + await RemoveFailuresAsync(attempt.ObservedFailures); + } + + public async Task ClearUserLoginAttemptsAsync(string emailAddress) + { + ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress); + var failures = await _cache.GetAllAsync(GetKeys($"user:{emailAddress.Trim().ToLowerInvariant()}", UserFailureLimit, 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))); + } + + private async Task ReserveAsync(string[] keys, string reservation, DateTime expiresUtc) + { + foreach (string key in keys) + if (await _cache.AddAsync(key, reservation, expiresUtc)) + return key; + return null; + } + + private Task ReleaseAsync(IEnumerable keys, string reservation) + => Task.WhenAll(keys.Select(key => _cache.RemoveIfEqualAsync(key, reservation))); + + private Task RemoveFailuresAsync(IEnumerable> failures) + => Task.WhenAll(failures.Select(failure => _cache.RemoveIfEqualAsync(failure.Key, failure.Value))); + + private DateTime GetWindowExpiration() => _timeProvider.GetUtcNow().UtcDateTime.Floor(AttemptWindow).Add(AttemptWindow); + + private static string[] GetKeys(string prefix, int limit, DateTime expiresUtc) + => Enumerable.Range(0, limit).Select(slot => $"{prefix}:attempts:{expiresUtc.Ticks}:{slot}").ToArray(); + + public sealed class LoginAttempt : IAsyncDisposable + { + private readonly AuthService _owner; + internal LoginAttempt(AuthService owner, DateTime expiresUtc, string[] keys, string reservation, KeyValuePair[] observedFailures) + { + _owner = owner; + ExpiresUtc = expiresUtc; + Keys = keys; + Reservation = reservation; + ObservedFailures = observedFailures; + } + internal DateTime ExpiresUtc { get; } + internal string[] Keys { get; } + internal string Reservation { get; } + internal KeyValuePair[] ObservedFailures { get; } + public ValueTask DisposeAsync() => new(_owner.ReleaseAsync(Keys, Reservation)); + } +} 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 1511233242..7c31ea8c65 100644 --- a/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs +++ b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs @@ -32,17 +32,19 @@ public class ApiKeyAuthenticationHandler : AuthenticationHandler options, + public ApiKeyAuthenticationHandler(ITokenRepository tokenRepository, IOAuthTokenRepository oauthTokenRepository, ICacheClient cacheClient, AuthService authService, IUserRepository userRepository, OAuthService oauthService, AppOptions appOptions, IOptionsMonitor options, TimeProvider timeProvider, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { _tokenRepository = tokenRepository; _oauthTokenRepository = oauthTokenRepository; _cacheClient = cacheClient; + _authService = authService; _userRepository = userRepository; _oauthService = oauthService; _appOptions = appOptions; @@ -67,36 +69,46 @@ protected override async Task HandleAuthenticateAsync() else if (authHeader is not null && scheme == BasicScheme) { var authInfo = Request.GetBasicAuth(); - if (authInfo is not null) + if (authInfo is null) { - if (authInfo.Username.ToLower() == "client") - token = authInfo.Password; - else if (authInfo.Password.ToLower() == "x-oauth-basic" || String.IsNullOrEmpty(authInfo.Password)) - token = authInfo.Username; - else + Logger.LogDebug("Invalid Basic authentication credentials on {Path}", Request.Path); + return AuthenticateResult.NoResult(); + } + + if (String.Equals(authInfo.Username, "client", StringComparison.OrdinalIgnoreCase)) + token = authInfo.Password; + else if (String.Equals(authInfo.Password, "x-oauth-basic", StringComparison.OrdinalIgnoreCase) || String.IsNullOrEmpty(authInfo.Password)) + token = authInfo.Username; + else + { + string emailAddress = authInfo.Username.Trim().ToLowerInvariant(); + string? ipAddress = Request.GetClientIpAddress(); + await using var loginAttempt = await _authService.TryBeginLoginAsync(emailAddress, ipAddress, Context.RequestAborted); + if (loginAttempt is null) { - User? user; - try - { - user = await _userRepository.GetByEmailAddressAsync(authInfo.Username); - } - catch (Exception ex) - { - return AuthenticateResult.Fail(ex); - } - - if (user is not { IsActive: true }) - return AuthenticateResult.Fail("User is not valid"); - - if (String.IsNullOrEmpty(user.Salt)) - return AuthenticateResult.Fail("User is not valid"); - - string encodedPassword = authInfo.Password.ToSaltedHash(user.Salt); - if (!String.Equals(encodedPassword, user.Password)) - return AuthenticateResult.Fail("User is not valid"); - - return AuthenticateResult.Success(CreateUserAuthenticationTicket(user)); + Logger.LogError("Login denied for {EmailAddress}", emailAddress); + return AuthenticateResult.Fail("Login denied."); } + + User? user; + try + { + user = await _userRepository.GetByEmailAddressAsync(emailAddress); + } + catch (Exception ex) + { + return AuthenticateResult.Fail(ex); + } + + if (user is not { IsActive: true } || !user.IsCorrectPassword(authInfo.Password)) + { + await _authService.RecordLoginFailureAsync(loginAttempt); + return AuthenticateResult.Fail("User is not valid"); + } + + await _authService.RecordLoginSuccessAsync(loginAttempt); + + return AuthenticateResult.Success(CreateUserAuthenticationTicket(user)); } } else diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index 1cc6d7a85f..1292de0fc4 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -1,5 +1,10 @@ using System.IdentityModel.Tokens.Jwt; using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; using Exceptionless.Core.Authorization; using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; @@ -13,10 +18,13 @@ using Exceptionless.Tests.Extensions; using Exceptionless.Tests.Utility; using Exceptionless.Web.Models; +using Exceptionless.Web.Security; using FluentRest; using Foundatio.Queues; using Foundatio.Repositories; using Foundatio.Repositories.Utility; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Xunit; using User = Exceptionless.Core.Models.User; @@ -560,6 +568,365 @@ public async Task LiveAsync_WithConfiguredProvider_ReturnsToken() await AssertExternalLoginAsync(result, "windowslive", code); } + + [Fact] + public async Task PasswordLogin_MissingRemoteIpAddress_StillEnforcesUserLimit() + { + // Arrange + using var client = _server.CreateClient(); + long originalTokenCount = (await _tokenRepository.CountAsync()).Total; + for (int attempt = 0; attempt < 5; attempt++) + { + using var failedResponse = await client.PostAsJsonAsync("api/v2/auth/login", + new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = "wrong-password" }, + TestCancellationToken); + Assert.Equal(HttpStatusCode.Unauthorized, failedResponse.StatusCode); + } + + // Act + using var response = await client.PostAsJsonAsync("api/v2/auth/login", + new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }, + TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(originalTokenCount, (await _tokenRepository.CountAsync()).Total); + } + + [Fact] + public async Task PasswordLogin_ThrottlingExpires_LogsInWithoutPasswordResetOrReactivation() + { + // Arrange + TimeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 14, 0, TimeSpan.Zero)); + using var client = _server.CreateClient(); + for (int failure = 0; failure < 5; failure++) + { + using var response = await client.PostAsJsonAsync("api/v2/auth/login", + new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = "wrong-password" }, + TestCancellationToken); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + var credentials = new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }; + + // Act + using var blocked = await client.PostAsJsonAsync("api/v2/auth/login", credentials, TestCancellationToken); + var throttledUser = await _userRepository.GetByEmailAddressAsync(credentials.Email); + TimeProvider.Advance(TimeSpan.FromMinutes(1)); + using var allowed = await client.PostAsJsonAsync("api/v2/auth/login", credentials, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, blocked.StatusCode); + Assert.NotNull(throttledUser); + Assert.True(throttledUser.IsActive); + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + } + + [Theory] + [InlineData(false, HttpStatusCode.Unauthorized)] + [InlineData(true, HttpStatusCode.OK)] + public async Task ResetPassword_PreservesActiveState_OnlyActiveUsersCanLogIn(bool isActive, HttpStatusCode expectedStatus) + { + // Arrange + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(user); + user = user with { IsActive = isActive }; + user.CreatePasswordResetToken(TimeProvider); + Assert.NotNull(user.PasswordResetToken); + await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); + using var client = _server.CreateClient(); + const string newPassword = "Password2$"; + + // Act + using var reset = await client.PostAsJsonAsync("api/v2/auth/reset-password", + new ResetPasswordModel { PasswordResetToken = user.PasswordResetToken, Password = newPassword }, + GetService(), TestCancellationToken); + var storedUser = await _userRepository.GetByIdAsync(user.Id, options => options.ImmediateConsistency()); + using var login = await client.PostAsJsonAsync("api/v2/auth/login", + new Login { Email = user.EmailAddress, Password = newPassword }, TestCancellationToken); + using var basicRequest = new HttpRequestMessage(HttpMethod.Get, "api/v2/users/me"); + basicRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user.EmailAddress}:{newPassword}"))); + using var basicLogin = await client.SendAsync(basicRequest, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, reset.StatusCode); + Assert.NotNull(storedUser); + Assert.Equal(isActive, storedUser.IsActive); + Assert.True(storedUser.IsCorrectPassword(newPassword)); + Assert.Equal(expectedStatus, login.StatusCode); + Assert.Equal(expectedStatus, basicLogin.StatusCode); + } + + [Fact] + public async Task ResetPassword_MissingRemoteIpAddress_ClearsUserLoginAttempts() + { + // Arrange + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(user); + user.CreatePasswordResetToken(TimeProvider); + Assert.NotNull(user.PasswordResetToken); + await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); + var authService = GetService(); + for (int failure = 0; failure < 5; failure++) + { + var loginAttempt = await authService.TryBeginLoginAsync(user.EmailAddress, "192.0.2.1", TestCancellationToken); + Assert.NotNull(loginAttempt); + await authService.RecordLoginFailureAsync(loginAttempt); + } + using var client = _server.CreateClient(); + + // Act + using var response = await client.PostAsJsonAsync("api/v2/auth/reset-password", + new ResetPasswordModel { PasswordResetToken = user.PasswordResetToken, Password = "Password2$" }, + GetService(), + TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(await authService.TryBeginLoginAsync(user.EmailAddress, "192.0.2.1", TestCancellationToken)); + } + + [Fact] + public async Task BasicPasswordLogin_ConcurrentValidRequests_DoNotConsumeFailureQuota() + { + // Arrange + using var client = _server.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{SampleDataService.TEST_USER_EMAIL}:{SampleDataService.TEST_USER_PASSWORD}"))); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var requests = Enumerable.Range(0, 30).Select(async request => + { + await start.Task; + using var response = await client.GetAsync("api/v2/users/me", TestCancellationToken); + return response.StatusCode; + }).ToArray(); + + // Act + start.SetResult(); + var results = await Task.WhenAll(requests); + + // Assert + Assert.Contains(HttpStatusCode.OK, results); + Assert.All(results, statusCode => Assert.True(statusCode is HttpStatusCode.OK or HttpStatusCode.Unauthorized)); + using var next = await client.GetAsync("api/v2/users/me", TestCancellationToken); + Assert.Equal(HttpStatusCode.OK, next.StatusCode); + } + + [Fact] + public async Task BasicPasswordLogin_MissingRemoteIpAddress_ReturnsCurrentUser() + { + // Arrange + using var client = _server.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "api/v2/users/me"); + string credentials = $"{SampleDataService.TEST_USER_EMAIL}:{SampleDataService.TEST_USER_PASSWORD}"; + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes(credentials))); + + // Act + using var response = await client.SendAsync(request, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task PasswordLogin_FailuresThroughBasic_AreThrottled() + { + // Arrange + for (int attempt = 0; attempt < 5; attempt++) + { + await SendRequestAsync(request => request + .BasicAuthorization(SampleDataService.TEST_USER_EMAIL, "wrong-password") + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + } + + // Act + var response = await SendRequestAsync(request => request + .Post() + .AppendPath("auth/login") + .Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }) + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task BasicPasswordLogin_FailuresThroughLogin_AreThrottled() + { + // Arrange + for (int attempt = 0; attempt < 5; attempt++) + { + await SendRequestAsync(request => request + .Post() + .AppendPath("auth/login") + .Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = "wrong-password" }) + .StatusCodeShouldBeUnauthorized()); + } + + // Act + var response = await SendRequestAsync(request => request + .BasicAuthorization(SampleDataService.TEST_USER_EMAIL, SampleDataService.TEST_USER_PASSWORD) + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task BasicPasswordLogin_AfterRepeatedFailures_IsThrottled() + { + // Arrange + const string email = "basic-throttle-user@exceptionless.test"; + const string password = "Password1$"; + const string salt = "1234567890123456"; + + var user = new User + { + EmailAddress = email, + Password = password.ToSaltedHash(salt), + Salt = salt, + FullName = "Basic Throttle User", + Roles = AuthorizationRoles.AllScopes + }; + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); + + for (int attempt = 0; attempt < 5; attempt++) + { + await SendRequestAsync(r => r + .BasicAuthorization(email, "wrong-password") + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + } + + // Act + var response = await SendRequestAsync(r => r + .BasicAuthorization(email, password) + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [InlineData("Basic !!!not-base64!!!")] + [InlineData("Basic")] + [InlineData("Basic ")] + [InlineData("Basic Og==")] + [InlineData("Basic ICA6cGFzc3dvcmQ=")] + public async Task BasicAuthentication_MalformedHeader_ReturnsUnauthorized(string authorization) + { + // Arrange + using var client = CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "users/me"); + request.Headers.TryAddWithoutValidation("Authorization", authorization); + + // Act + using var response = await client.SendAsync(request, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [InlineData("Basic")] + [InlineData("Basic !!!not-base64!!!")] + [InlineData("Basic Og==")] + public async Task BasicAuthentication_UnparseableCredentials_ReturnsNoResult(string authorization) + { + // Arrange + using var scope = _server.Services.CreateScope(); + var context = new DefaultHttpContext { RequestServices = scope.ServiceProvider }; + context.Request.Headers.Authorization = authorization; + context.Request.QueryString = new QueryString("?api_key=" + SampleDataService.TEST_API_KEY); + + // Act + var result = await context.AuthenticateAsync(ApiKeyAuthenticationOptions.ApiKeySchema); + + // Assert + Assert.True(result.None); + Assert.Null(result.Failure); + Assert.Null(result.Principal); + } + + [Theory] + [InlineData(null, "hash")] + [InlineData("", "hash")] + [InlineData("salt", null)] + [InlineData("salt", "")] + public async Task BasicPasswordLogin_MissingStoredCredentials_ReturnsUnauthorized(string? salt, string? passwordHash) + { + // Arrange + var user = new User + { + EmailAddress = "missing-credentials@exceptionless.test", + FullName = "Missing Credentials", + Salt = salt, + Password = passwordHash, + Roles = new HashSet([AuthorizationRoles.Client, AuthorizationRoles.User]) + }; + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user, options => options.ImmediateConsistency()); + + // Act + var response = await SendRequestAsync(request => request + .BasicAuthorization(user.EmailAddress, "Password1$") + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [InlineData("Basic", "client:{token}")] + [InlineData("Basic", "{token}:")] + [InlineData("bAsIc", "{token}:x-oauth-basic")] + [InlineData("Basic ", "client:{token}")] + public async Task BasicAuthentication_AuthenticationToken_ReturnsCurrentUser(string scheme, string credentialsTemplate) + { + // Arrange + var login = await SendRequestAsAsync(request => request + .Post() + .AppendPath("auth/login") + .Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }) + .StatusCodeShouldBeOk()); + Assert.NotNull(login); + string credentials = credentialsTemplate.Replace("{token}", login.Token, StringComparison.Ordinal); + using var client = CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "users/me"); + request.Headers.TryAddWithoutValidation("Authorization", $"{scheme} {Convert.ToBase64String(Encoding.UTF8.GetBytes(credentials))}"); + + // Act + using var response = await client.SendAsync(request, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task BasicAuthentication_PasswordContainingColons_ReturnsCurrentUser() + { + // Arrange + const string password = "Pass:word:1$"; + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(user); + Assert.NotNull(user.Salt); + user.Password = password.ToSaltedHash(user.Salt); + await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); + + // Act + var response = await SendRequestAsync(request => request + .BasicAuthorization(user.EmailAddress, password).AppendPath("users/me").StatusCodeShouldBeOk()); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + [Fact] public async Task GitHubAsync_WithoutInviteAndAuthenticatedSession_LinksCurrentUser() { diff --git a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs index 8aa22eca18..056a1cb5af 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/AuthHandlerTests.cs @@ -5,6 +5,7 @@ using Exceptionless.Core.Mail; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; using Exceptionless.Web.Api.Handlers; using Exceptionless.Web.Api.Messages; using Exceptionless.Web.Models; @@ -59,6 +60,7 @@ private AuthHandler CreateHandler(Exception repositoryException) GetService(), GetService(), GetService(), + GetService(), GetService(), GetService(), TimeProvider, diff --git a/tests/Exceptionless.Tests/AppWebHostFactory.cs b/tests/Exceptionless.Tests/AppWebHostFactory.cs index 87edc24c4f..c11b2a10fe 100644 --- a/tests/Exceptionless.Tests/AppWebHostFactory.cs +++ b/tests/Exceptionless.Tests/AppWebHostFactory.cs @@ -21,7 +21,7 @@ namespace Exceptionless.Tests; public class AppWebHostFactory : WebApplicationFactory, IAsyncLifetime { - private const string SharedElasticsearchUrl = "http://localhost:9200"; + private static readonly string SharedElasticsearchUrl = GetElasticsearchUrl(); private static readonly TimeSpan SharedElasticsearchStartupTimeout = TimeSpan.FromMinutes(3); private static int s_counter = -1; private static readonly Lazy> s_sharedAppHost = new(StartSharedAppHostAsync, LazyThreadSafetyMode.ExecutionAndPublication); @@ -43,10 +43,19 @@ public AppWebHostFactory() public async ValueTask InitializeAsync() { - _ = await s_sharedAppHost.Value; + if (String.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("EX_TestElasticsearchUrl"))) + _ = await s_sharedAppHost.Value; await WaitForElasticsearchAsync(new Uri(SharedElasticsearchUrl)); } + private static string GetElasticsearchUrl() + { + string url = Environment.GetEnvironmentVariable("EX_TestElasticsearchUrl") ?? "http://localhost:9200"; + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || !uri.IsLoopback || uri.Scheme != Uri.UriSchemeHttp) + throw new InvalidOperationException("Test Elasticsearch must use a local HTTP endpoint."); + return url; + } + private static async Task StartSharedAppHostAsync() { var appHost = await DistributedApplicationTestingBuilder.CreateAsync( diff --git a/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs new file mode 100644 index 0000000000..d3b414c007 --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs @@ -0,0 +1,71 @@ +using System.Text; +using Exceptionless.Web.Extensions; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Exceptionless.Tests.Extensions; + +public sealed class HttpExtensionsTests +{ + [Theory] + [InlineData("Basic", "user@example.com", "password")] + [InlineData("bAsIc", "user@example.com", "password")] + [InlineData("Basic ", "user@example.com", "password")] + [InlineData("Basic", " user@example.com ", " password ")] + [InlineData("Basic", "user@example.com", "pässwörd")] + [InlineData("Basic", "user@example.com", "pass:word:with:colons")] + [InlineData("Basic", "api-token", "")] + [InlineData("Basic", "client", "api-token")] + [InlineData("Basic", "api-token", "x-oauth-basic")] + public void GetBasicAuth_ValidCredentials_PreservesUsernameAndPassword(string scheme, string username, string password) + { + // Arrange + var request = new DefaultHttpContext().Request; + string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + request.Headers.Authorization = $"{scheme} {encoded} "; + + // Act + var credentials = request.GetBasicAuth(); + + // Assert + Assert.NotNull(credentials); + Assert.Equal(username, credentials.Username); + Assert.Equal(password, credentials.Password); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Basic")] + [InlineData("Basic ")] + [InlineData("Basic !!!")] + [InlineData("Basic abc")] + [InlineData("Basic Og==")] + [InlineData("Basic OnBhc3N3b3Jk")] + [InlineData("Basic ICA6cGFzc3dvcmQ=")] + [InlineData("Bearer dXNlcjpwYXNzd29yZA==")] + [InlineData("BasicOther dXNlcjpwYXNzd29yZA==")] + public void GetBasicAuth_InvalidHeader_ReturnsNull(string? authorization) + { + // Arrange + var request = new DefaultHttpContext().Request; + request.Headers.Authorization = authorization; + + // Act + var credentials = request.GetBasicAuth(); + + // Assert + Assert.Null(credentials); + } + + [Fact] + public void GetBasicAuth_NullRequest_ThrowsArgumentNullException() + { + // Act + var exception = Assert.Throws(() => HttpExtensions.GetBasicAuth(null!)); + + // Assert + Assert.Equal("request", exception.ParamName); + } +} diff --git a/tests/Exceptionless.Tests/Services/AuthServiceTests.cs b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs new file mode 100644 index 0000000000..22c1fda5d1 --- /dev/null +++ b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs @@ -0,0 +1,178 @@ +using Exceptionless.Core.Services; +using Foundatio.Caching; +using Xunit; + +namespace Exceptionless.Tests.Services; + +public sealed class AuthServiceTests(ITestOutputHelper output) : TestWithServices(output) +{ + [Fact] + public async Task TryBeginLoginAsync_ConcurrentInstances_BoundsChecksBeforeFailuresComplete() + { + var first = GetService(); + var second = new AuthService(GetService(), TimeProvider); + var attempts = await Task.WhenAll(Enumerable.Range(0, 100).Select(i => + (i % 2 == 0 ? first : second).TryBeginLoginAsync(" User@exceptionless.test ", null, TestCancellationToken))); + Assert.Equal(5, attempts.Count(a => a is not null)); + await Task.WhenAll(attempts.Where(a => a is not null).Select(a => first.RecordLoginFailureAsync(a!))); + Assert.Null(await second.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken)); + } + + [Fact] + public async Task TryBeginLoginAsync_ConcurrentUsers_BoundsChecksAtSharedIpAddress() + { + var service = GetService(); + var attempts = await Task.WhenAll(Enumerable.Range(0, 100).Select(i => + service.TryBeginLoginAsync($"user{i}@exceptionless.test", "192.0.2.1", TestCancellationToken))); + Assert.Equal(15, attempts.Count(a => a is not null)); + await Task.WhenAll(attempts.Where(a => a is not null).Select(a => service.RecordLoginFailureAsync(a!))); + Assert.Null(await service.TryBeginLoginAsync("other@exceptionless.test", "192.0.2.1", TestCancellationToken)); + // Rejected IP admission must release the partially reserved account slot. + await using var allowed = await service.TryBeginLoginAsync("user99@exceptionless.test", "192.0.2.2", TestCancellationToken); + Assert.NotNull(allowed); + } + + [Fact] + public async Task RecordLoginSuccessAsync_ValidRequests_DoNotConsumeFailureQuota() + { + var service = GetService(); + 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 Task.WhenAll(attempts.Select(a => a.DisposeAsync().AsTask())); + } + await using var next = await BeginAsync(service); + } + + [Fact] + public async Task DisposeAsync_InterruptedAttempt_ReleasesBothReservations() + { + var service = GetService(); + for (int i = 0; i < 30; i++) + await (await BeginAsync(service)).DisposeAsync(); + await using var next = await BeginAsync(service); + } + + [Fact] + public async Task DisposeAsync_CompletedFailure_RetainsCharge() + { + var service = GetService(); + for (int i = 0; i < 5; i++) + { + await using var attempt = await BeginAsync(service); + await service.RecordLoginFailureAsync(attempt); + } + Assert.Null(await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken)); + } + + [Fact] + public async Task RecordLoginSuccessAsync_PreservesNewFailuresAndOtherReservations() + { + var service = GetService(); + await FailAsync(service); + 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 service.RecordLoginSuccessAsync(success); + await FailAsync(service); + await FailAsync(service); + await service.RecordLoginSuccessAsync(success); + Assert.Null(await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken)); + } + + [Fact] + public async Task RecordLoginSuccessAsync_DoesNotRefundOtherUsersIpFailures() + { + var service = GetService(); + for (int i = 0; i < 14; i++) + await FailAsync(service, $"other{i}@exceptionless.test"); + await service.RecordLoginSuccessAsync(await BeginAsync(service)); + await FailAsync(service, "last@exceptionless.test"); + Assert.Null(await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken)); + } + + [Fact] + public async Task ClearUserLoginAttemptsAsync_PreservesIpFailuresAndChecksUnderway() + { + var service = GetService(); + for (int i = 0; i < 4; i++) + await FailAsync(service); + var pending = await BeginAsync(service); + await service.ClearUserLoginAttemptsAsync(" User@exceptionless.test "); + var remaining = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken))); + Assert.Equal(4, remaining.Count(a => a is not null)); + await pending.DisposeAsync(); + await Task.WhenAll(remaining.Where(a => a is not null).Select(a => a!.DisposeAsync().AsTask())); + for (int i = 0; i < 11; i++) + await FailAsync(service, $"other{i}@exceptionless.test"); + Assert.Null(await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken)); + } + + [Fact] + public async Task TryBeginLoginAsync_QuarterHour_ExpiresFailuresAndAbandonedReservations() + { + TimeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 14, 0, TimeSpan.Zero)); + var service = GetService(); + var old = await BeginAsync(service); + for (int i = 0; i < 4; i++) + await FailAsync(service); + Assert.Null(await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken)); + TimeProvider.Advance(TimeSpan.FromMinutes(1)); + for (int i = 0; i < 5; i++) + await FailAsync(service); + await service.RecordLoginSuccessAsync(old); + await service.RecordLoginFailureAsync(old); + Assert.Null(await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken)); + TimeProvider.Advance(TimeSpan.FromMinutes(15)); + await using var next = await BeginAsync(service); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task TryBeginLoginAsync_InvalidEmail_Throws(string? email) + { + var service = GetService(); + await Assert.ThrowsAnyAsync(() => service.TryBeginLoginAsync(email!, null, TestCancellationToken)); + await Assert.ThrowsAnyAsync(() => service.ClearUserLoginAttemptsAsync(email!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public Task TryBeginLoginAsync_InvalidIpAddress_Throws(string address) + => Assert.ThrowsAnyAsync(() => GetService().TryBeginLoginAsync("user@example.test", address, TestCancellationToken)); + + [Fact] + public async Task TryBeginLoginAsync_CancelledRequest_Throws() + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + await cancellation.CancelAsync(); + await Assert.ThrowsAsync(() => GetService().TryBeginLoginAsync("user@example.test", null, cancellation.Token)); + } + + [Fact] + public async Task RecordLoginAsync_NullAttempt_Throws() + { + var service = GetService(); + await Assert.ThrowsAsync(() => service.RecordLoginFailureAsync(null!)); + await Assert.ThrowsAsync(() => service.RecordLoginSuccessAsync(null!)); + Assert.Throws(() => new AuthService(null!, TimeProvider)); + Assert.Throws(() => new AuthService(GetService(), null!)); + } + + 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 async Task FailAsync(AuthService service, string email = "user@exceptionless.test") + { + await using var attempt = await BeginAsync(service, email); + await service.RecordLoginFailureAsync(attempt); + } +} diff --git a/tests/http/users.http b/tests/http/users.http index b996b9ee8f..563b8a6efc 100644 --- a/tests/http/users.http +++ b/tests/http/users.http @@ -16,6 +16,14 @@ Content-Type: application/json @token = {{login.response.body.$.token}} +### Password authentication shares the login attempt window +GET {{apiUrl}}/users/me +Authorization: Basic {{email}}:{{password}} + +### Authentication token through the client alias +GET {{apiUrl}}/users/me +Authorization: Basic client:{{token}} + ### Get Current User # @name currentUser GET {{apiUrl}}/users/me From 9e9282d93ba3814aca9eaf03584b548a194e5112 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 4 Oct 2026 19:33:23 -0500 Subject: [PATCH 2/9] Clarify login admission cache keys and regression coverage --- .../Services/AuthService.cs | 75 +- .../Api/Endpoints/AuthEndpointTests.cs | 2299 ++++++++--------- .../Extensions/HttpExtensionsTests.cs | 58 +- .../Services/AuthServiceTests.cs | 389 ++- 4 files changed, 1533 insertions(+), 1288 deletions(-) diff --git a/src/Exceptionless.Core/Services/AuthService.cs b/src/Exceptionless.Core/Services/AuthService.cs index 696d0fb044..dcbfde321e 100644 --- a/src/Exceptionless.Core/Services/AuthService.cs +++ b/src/Exceptionless.Core/Services/AuthService.cs @@ -18,6 +18,7 @@ public AuthService(ICacheClient cacheClient, TimeProvider timeProvider) { ArgumentNullException.ThrowIfNull(cacheClient); ArgumentNullException.ThrowIfNull(timeProvider); + _cache = new ScopedCacheClient(cacheClient, "Auth"); _timeProvider = timeProvider; } @@ -25,39 +26,46 @@ public AuthService(ICacheClient cacheClient, TimeProvider timeProvider) public async Task TryBeginLoginAsync(string emailAddress, string? ipAddress, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress); + if (ipAddress is not null) ArgumentException.ThrowIfNullOrWhiteSpace(ipAddress); + cancellationToken.ThrowIfCancellationRequested(); var expiresUtc = GetWindowExpiration(); - string[] userKeys = GetKeys($"user:{emailAddress.Trim().ToLowerInvariant()}", UserFailureLimit, expiresUtc); - var failures = await _cache.GetAllAsync(userKeys); + string[] userCacheKeys = GetCacheKeys($"user:{emailAddress.Trim().ToLowerInvariant()}", UserFailureLimit, 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 keys = new List(2); + var reservedCacheKeys = new List(2); try { - string? userKey = await ReserveAsync(userKeys, reservation, expiresUtc); - if (userKey is null) + string? userCacheKey = await ReserveCacheKeyAsync(userCacheKeys, reservation, expiresUtc); + if (userCacheKey is null) return null; - keys.Add(userKey); + + reservedCacheKeys.Add(userCacheKey); + if (ipAddress is not null) { - string? ipKey = await ReserveAsync(GetKeys($"ip:{ipAddress}", IpAddressFailureLimit, expiresUtc), reservation, expiresUtc); - if (ipKey is null) + string? ipAddressCacheKey = await ReserveCacheKeyAsync(GetCacheKeys($"ip:{ipAddress}", IpAddressFailureLimit, expiresUtc), reservation, expiresUtc); + if (ipAddressCacheKey is null) { - await ReleaseAsync(keys, reservation); + await ReleaseCacheKeysAsync(reservedCacheKeys, reservation); return null; } - keys.Add(ipKey); + + reservedCacheKeys.Add(ipAddressCacheKey); } + cancellationToken.ThrowIfCancellationRequested(); - return new LoginAttempt(this, expiresUtc, keys.ToArray(), reservation, observedFailures); + + return new LoginAttempt(this, expiresUtc, reservedCacheKeys.ToArray(), reservation, observedFailures); } catch { - await ReleaseAsync(keys, reservation); + await ReleaseCacheKeysAsync(reservedCacheKeys, reservation); throw; } } @@ -65,64 +73,73 @@ public AuthService(ICacheClient cacheClient, TimeProvider timeProvider) public async Task RecordLoginFailureAsync(LoginAttempt attempt) { ArgumentNullException.ThrowIfNull(attempt); + var remaining = attempt.ExpiresUtc - _timeProvider.GetUtcNow().UtcDateTime; if (remaining <= TimeSpan.Zero) return; - // A crashed worker stays charged until the boundary, so a slow check cannot - // outlive its reservation and silently restore admission. - await Task.WhenAll(attempt.Keys.Select(key => _cache.ReplaceIfEqualAsync(key, $"failed:{attempt.Reservation}", attempt.Reservation, remaining))); + + // 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 => _cache.ReplaceIfEqualAsync(cacheKey, $"failed:{attempt.Reservation}", attempt.Reservation, remaining))); } public async Task RecordLoginSuccessAsync(LoginAttempt attempt) { ArgumentNullException.ThrowIfNull(attempt); - await ReleaseAsync(attempt.Keys, attempt.Reservation); + + await ReleaseCacheKeysAsync(attempt.CacheKeys, attempt.Reservation); await RemoveFailuresAsync(attempt.ObservedFailures); } public async Task ClearUserLoginAttemptsAsync(string emailAddress) { ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress); - var failures = await _cache.GetAllAsync(GetKeys($"user:{emailAddress.Trim().ToLowerInvariant()}", UserFailureLimit, GetWindowExpiration())); + + var failures = await _cache.GetAllAsync(GetCacheKeys($"user:{emailAddress.Trim().ToLowerInvariant()}", UserFailureLimit, 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))); } - private async Task ReserveAsync(string[] keys, string reservation, DateTime expiresUtc) + private async Task ReserveCacheKeyAsync(string[] cacheKeys, string reservation, DateTime expiresUtc) { - foreach (string key in keys) - if (await _cache.AddAsync(key, reservation, expiresUtc)) - return key; + foreach (string cacheKey in cacheKeys) + if (await _cache.AddAsync(cacheKey, reservation, expiresUtc)) + return cacheKey; + return null; } - private Task ReleaseAsync(IEnumerable keys, string reservation) - => Task.WhenAll(keys.Select(key => _cache.RemoveIfEqualAsync(key, reservation))); + private Task ReleaseCacheKeysAsync(IEnumerable cacheKeys, string reservation) + => Task.WhenAll(cacheKeys.Select(cacheKey => _cache.RemoveIfEqualAsync(cacheKey, reservation))); private Task RemoveFailuresAsync(IEnumerable> failures) => Task.WhenAll(failures.Select(failure => _cache.RemoveIfEqualAsync(failure.Key, failure.Value))); private DateTime GetWindowExpiration() => _timeProvider.GetUtcNow().UtcDateTime.Floor(AttemptWindow).Add(AttemptWindow); - private static string[] GetKeys(string prefix, int limit, DateTime expiresUtc) - => Enumerable.Range(0, limit).Select(slot => $"{prefix}:attempts:{expiresUtc.Ticks}:{slot}").ToArray(); + // The window selects expiration; separate cache entries atomically reserve admission. + private static string[] GetCacheKeys(string cacheKeyPrefix, int limit, DateTime expiresUtc) + => Enumerable.Range(0, limit).Select(index => $"{cacheKeyPrefix}:attempts:{expiresUtc.Ticks}:{index}").ToArray(); public sealed class LoginAttempt : IAsyncDisposable { private readonly AuthService _owner; - internal LoginAttempt(AuthService owner, DateTime expiresUtc, string[] keys, string reservation, KeyValuePair[] observedFailures) + + internal LoginAttempt(AuthService owner, DateTime expiresUtc, string[] cacheKeys, string reservation, KeyValuePair[] observedFailures) { _owner = owner; ExpiresUtc = expiresUtc; - Keys = keys; + CacheKeys = cacheKeys; Reservation = reservation; ObservedFailures = observedFailures; } + internal DateTime ExpiresUtc { get; } - internal string[] Keys { get; } + internal string[] CacheKeys { get; } internal string Reservation { get; } internal KeyValuePair[] ObservedFailures { get; } - public ValueTask DisposeAsync() => new(_owner.ReleaseAsync(Keys, Reservation)); + + public ValueTask DisposeAsync() => new(_owner.ReleaseCacheKeysAsync(CacheKeys, Reservation)); } } diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs index 1292de0fc4..35799bfde6 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -81,81 +81,509 @@ private void ConfigureAuthOptions() _authOptions.MicrosoftSecret = "microsoft-client-secret"; } + [Theory] + [InlineData("Basic", "client:{token}")] + [InlineData("Basic", "{token}:")] + [InlineData("bAsIc", "{token}:x-oauth-basic")] + [InlineData("Basic ", "client:{token}")] + public async Task BasicAuthentication_AuthenticationToken_ReturnsCurrentUser(string scheme, string credentialsTemplate) + { + // Arrange + var login = await SendRequestAsAsync(request => request + .Post() + .AppendPath("auth/login") + .Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }) + .StatusCodeShouldBeOk()); + Assert.NotNull(login); + string credentials = credentialsTemplate.Replace("{token}", login.Token, StringComparison.Ordinal); + using var client = CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "users/me"); + request.Headers.TryAddWithoutValidation("Authorization", $"{scheme} {Convert.ToBase64String(Encoding.UTF8.GetBytes(credentials))}"); + + // Act + using var response = await client.SendAsync(request, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData("Basic !!!not-base64!!!")] + [InlineData("Basic")] + [InlineData("Basic ")] + [InlineData("Basic Og==")] + [InlineData("Basic ICA6cGFzc3dvcmQ=")] + public async Task BasicAuthentication_MalformedHeader_ReturnsUnauthorized(string authorization) + { + // Arrange + using var client = CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "users/me"); + request.Headers.TryAddWithoutValidation("Authorization", authorization); + + // Act + using var response = await client.SendAsync(request, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + [Fact] - public async Task CannotSignupWithoutPassword() + public async Task BasicAuthentication_PasswordContainingColons_ReturnsCurrentUser() { - var problemDetails = await SendRequestAsAsync(r => r + // Arrange + const string password = "Pass:word:1$"; + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(user); + Assert.NotNull(user.Salt); + user.Password = password.ToSaltedHash(user.Salt); + await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); + + // Act + var response = await SendRequestAsync(request => request + .BasicAuthorization(user.EmailAddress, password).AppendPath("users/me").StatusCodeShouldBeOk()); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData("Basic")] + [InlineData("Basic !!!not-base64!!!")] + [InlineData("Basic Og==")] + public async Task BasicAuthentication_UnparseableCredentials_ReturnsNoResult(string authorization) + { + // Arrange + using var scope = _server.Services.CreateScope(); + var context = new DefaultHttpContext { RequestServices = scope.ServiceProvider }; + context.Request.Headers.Authorization = authorization; + context.Request.QueryString = new QueryString("?api_key=" + SampleDataService.TEST_API_KEY); + + // Act + var result = await context.AuthenticateAsync(ApiKeyAuthenticationOptions.ApiKeySchema); + + // Assert + Assert.True(result.None); + Assert.Null(result.Failure); + Assert.Null(result.Principal); + } + + [Fact] + public async Task BasicPasswordLogin_AfterRepeatedFailures_IsThrottled() + { + // Arrange + const string email = "basic-throttle-user@exceptionless.test"; + const string password = "Password1$"; + const string salt = "1234567890123456"; + + var user = new User + { + EmailAddress = email, + Password = password.ToSaltedHash(salt), + Salt = salt, + FullName = "Basic Throttle User", + Roles = AuthorizationRoles.AllScopes + }; + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); + + for (int attempt = 0; attempt < 5; attempt++) + { + await SendRequestAsync(r => r + .BasicAuthorization(email, "wrong-password") + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + } + + // Act + var response = await SendRequestAsync(r => r + .BasicAuthorization(email, password) + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task BasicPasswordLogin_ConcurrentValidRequests_DoNotConsumeFailureQuota() + { + // Arrange + using var client = _server.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{SampleDataService.TEST_USER_EMAIL}:{SampleDataService.TEST_USER_PASSWORD}"))); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var requests = Enumerable.Range(0, 30).Select(async request => + { + await start.Task; + using var response = await client.GetAsync("api/v2/users/me", TestCancellationToken); + return response.StatusCode; + }).ToArray(); + + // Act + start.SetResult(); + var results = await Task.WhenAll(requests); + using var next = await client.GetAsync("api/v2/users/me", TestCancellationToken); + + // 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++) + { + await SendRequestAsync(request => request + .Post() + .AppendPath("auth/login") + .Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = "wrong-password" }) + .StatusCodeShouldBeUnauthorized()); + } + + // Act + var response = await SendRequestAsync(request => request + .BasicAuthorization(SampleDataService.TEST_USER_EMAIL, SampleDataService.TEST_USER_PASSWORD) + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task BasicPasswordLogin_MissingRemoteIpAddress_ReturnsCurrentUser() + { + // Arrange + using var client = _server.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "api/v2/users/me"); + string credentials = $"{SampleDataService.TEST_USER_EMAIL}:{SampleDataService.TEST_USER_PASSWORD}"; + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes(credentials))); + + // Act + using var response = await client.SendAsync(request, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData(null, "hash")] + [InlineData("", "hash")] + [InlineData("salt", null)] + [InlineData("salt", "")] + public async Task BasicPasswordLogin_MissingStoredCredentials_ReturnsUnauthorized(string? salt, string? passwordHash) + { + // Arrange + var user = new User + { + EmailAddress = "missing-credentials@exceptionless.test", + FullName = "Missing Credentials", + Salt = salt, + Password = passwordHash, + Roles = new HashSet([AuthorizationRoles.Client, AuthorizationRoles.User]) + }; + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user, options => options.ImmediateConsistency()); + + // Act + var response = await SendRequestAsync(request => request + .BasicAuthorization(user.EmailAddress, "Password1$") + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task CanChangePasswordAsync() + { + 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(); + await _userRepository.AddAsync(user); + + var result = await SendRequestAsAsync(r => r .Post() - .AppendPath("auth/signup") - .Content(new Signup + .AppendPath("auth/login") + .Content(new Login { - Name = "hello", - Email = "test@domain.com", - Password = null! + Email = email, + 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); + + 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"; + var changePasswordResult = await SendRequestAsAsync(r => r + .Post() + .BasicAuthorization(email, password) + .AppendPath("auth/change-password") + .Content(new ChangePasswordModel + { + CurrentPassword = password, + Password = newPassword + }) + .StatusCodeShouldBeOk() + ); + + 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)); } - [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) + [Fact] + public async Task CanLogoutClientAccessTokenAsync() { - _authOptions.EnableAccountCreation = false; - _authOptions.EnableActiveDirectoryAuth = enableAdAuth; + var token = await _tokenRepository.GetByIdAsync(TestConstants.ApiKey); + Assert.NotNull(token); + Assert.Equal(TokenType.Access, token.Type); + Assert.False(token.IsDisabled); + Assert.False(token.IsSuspended); - if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) + 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); + } + + [Fact] + public async Task CanLogoutUserAccessTokenAsync() + { + var token = await _tokenRepository.GetByIdAsync(TestConstants.UserApiKey); + 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); + } + + [Fact] + public async Task CanLogoutUserAsync() + { + const string email = "test7@exceptionless.io"; + const string password = "Test7 password"; + const string salt = "1234567890123456"; + string passwordHash = password.ToSaltedHash(salt); + + var user = new User { - var provider = new TestDomainLoginProvider(); - email = provider.GetEmailAddressFromUsername(email); - } + EmailAddress = email, + Password = passwordHash, + Salt = salt, + FullName = "User 7", + Roles = AuthorizationRoles.AllScopes + }; - return SendRequestAsync(r => r + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); + + var result = await SendRequestAsAsync(r => r .Post() - .AppendPath("auth/signup") - .Content(new Signup + .AppendPath("auth/login") + .Content(new Login { - Name = "Test", Email = email, Password = password, - InviteToken = null }) - .StatusCodeShouldBeForbidden() + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(result); + + // 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 SendRequestAsync(r => r + .BearerToken(result.Token) + .AppendPath("auth/logout") + .StatusCodeShouldBeOk() ); + + token = await _tokenRepository.GetByIdAsync(result.Token); + Assert.Null(token); } - [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 CanResetPasswordAsync() + { + 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() + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = password, + }) + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(result); + Assert.NotEmpty(result.Token); + + 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"; + await SendRequestAsync(r => r + .Post() + .BasicAuthorization(email, password) + .AppendPath("auth/reset-password") + .Content(new ResetPasswordModel + { + PasswordResetToken = user.PasswordResetToken, + Password = newPassword + }) + .StatusCodeShouldBeOk() + ); + + Assert.Null(await _tokenRepository.GetByIdAsync(result.Token)); + Assert.Null(await _oauthTokenRepository.GetByIdAsync(oauthToken.Id, o => o.ImmediateConsistency())); + } + + [Fact] + public async Task CanSignupWhenAccountCreationDisabledWithValidTokenAndInvalidAdAccountAsync() { _authOptions.EnableAccountCreation = false; - _authOptions.EnableActiveDirectoryAuth = enableAdAuth; + _authOptions.EnableActiveDirectoryAuth = true; - if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) + 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 { - var provider = new TestDomainLoginProvider(); - email = provider.GetEmailAddressFromUsername(email); - } + 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)); - return SendRequestAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = email, - Password = password, - InviteToken = StringExtensions.GetNewToken() - }) - .StatusCodeShouldBeForbidden() + await SendRequestAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Test", + Email = email, + Password = password, + InviteToken = invite.Token + }) + .StatusCodeShouldBeUnauthorized() ); } @@ -216,48 +644,33 @@ public async Task CanSignupWhenAccountCreationDisabledWithValidTokenAsync(bool e } [Fact] - public async Task CanSignupWhenAccountCreationDisabledWithValidTokenAndInvalidAdAccountAsync() + public Task CanSignupWhenAccountCreationEnabledWithNoTokenAndInvalidAdAccountAsync() { - _authOptions.EnableAccountCreation = false; + _authOptions.EnableAccountCreation = true; _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 - { - 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)); - - await SendRequestAsync(r => r + return SendRequestAsync(r => r .Post() .AppendPath("auth/signup") .Content(new Signup { Name = "Test", - Email = email, - Password = password, - InviteToken = invite.Token + Email = "testuser2@exceptionless.io", + Password = "literallydoesntmatter", + InviteToken = null }) .StatusCodeShouldBeUnauthorized() ); } [Fact] - public async Task CanSignupWhenAccountCreationEnabledWithNoTokenAsync() + public async Task CanSignupWhenAccountCreationEnabledWithNoTokenAndValidAdAccountAsync() { _authOptions.EnableAccountCreation = true; + _authOptions.EnableActiveDirectoryAuth = true; - const string email = "test4@exceptionless.io"; - const string password = "Password1$"; + var provider = new TestDomainLoginProvider(); + string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); var result = await SendRequestAsAsync(r => r .Post() @@ -266,7 +679,7 @@ public async Task CanSignupWhenAccountCreationEnabledWithNoTokenAsync() { Name = "Test", Email = email, - Password = password, + Password = TestDomainLoginProvider.ValidPassword, InviteToken = null }) .StatusCodeShouldBeOk() @@ -274,27 +687,15 @@ public async Task CanSignupWhenAccountCreationEnabledWithNoTokenAsync() 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 CanSignupWhenAccountCreationEnabledWithNoTokenAndValidAdAccountAsync() + public async Task CanSignupWhenAccountCreationEnabledWithNoTokenAsync() { _authOptions.EnableAccountCreation = true; - _authOptions.EnableActiveDirectoryAuth = true; - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); + const string email = "test4@exceptionless.io"; + const string password = "Password1$"; var result = await SendRequestAsAsync(r => r .Post() @@ -303,7 +704,7 @@ public async Task CanSignupWhenAccountCreationEnabledWithNoTokenAndValidAdAccoun { Name = "Test", Email = email, - Password = TestDomainLoginProvider.ValidPassword, + Password = password, InviteToken = null }) .StatusCodeShouldBeOk() @@ -311,90 +712,50 @@ public async Task CanSignupWhenAccountCreationEnabledWithNoTokenAndValidAdAccoun Assert.NotNull(result); Assert.False(String.IsNullOrEmpty(result.Token)); - } - [Fact] - public Task CanSignupWhenAccountCreationEnabledWithNoTokenAndInvalidAdAccountAsync() - { - _authOptions.EnableAccountCreation = true; - _authOptions.EnableActiveDirectoryAuth = true; + 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); - return SendRequestAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = "testuser2@exceptionless.io", - Password = "literallydoesntmatter", - InviteToken = null - }) - .StatusCodeShouldBeUnauthorized() - ); + Assert.False(user.IsEmailAddressVerified); + Assert.NotNull(user.VerifyEmailAddressToken); + Assert.NotEqual(DateTime.MinValue, user.VerifyEmailAddressTokenExpiration); } [Fact] - public async Task CanSignupWhenAccountCreationEnabledWithValidTokenAsync() + public async Task CanSignupWhenAccountCreationEnabledWithValidTokenAndInvalidAdAccountAsync() { _authOptions.EnableAccountCreation = true; + _authOptions.EnableActiveDirectoryAuth = 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$"; - + 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.Clear(); organization.Invites.Add(invite); await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); Assert.NotNull(organization.GetInvite(invite.Token)); - var result = await SendRequestAsAsync(r => r + await SendRequestAsync(r => r .Post() .AppendPath("auth/signup") .Content(new Signup { - Name = name, + Name = "Test", Email = email, - Password = password, + Password = TestDomainLoginProvider.ValidPassword, InviteToken = invite.Token }) - .StatusCodeShouldBeOk() + .StatusCodeShouldBeUnauthorized() ); - - 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); - - 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] @@ -436,528 +797,449 @@ public async Task CanSignupWhenAccountCreationEnabledWithValidTokenAndValidAdAcc } [Fact] - public async Task CanSignupWhenAccountCreationEnabledWithValidTokenAndInvalidAdAccountAsync() + public async Task CanSignupWhenAccountCreationEnabledWithValidTokenAsync() { _authOptions.EnableAccountCreation = true; - _authOptions.EnableActiveDirectoryAuth = true; - string email = "test-user4@exceptionless.io"; - var results = await _organizationRepository.GetAllAsync(); - var organization = results.Documents.First(); + 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 { Token = StringExtensions.GetNewToken(), EmailAddress = email.ToLowerInvariant(), DateAdded = DateTime.UtcNow }; + + organization.Invites.Clear(); organization.Invites.Add(invite); await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); Assert.NotNull(organization.GetInvite(invite.Token)); - await SendRequestAsync(r => r + var result = await SendRequestAsAsync(r => r .Post() .AppendPath("auth/signup") .Content(new Signup { - Name = "Test", + Name = name, Email = email, - Password = TestDomainLoginProvider.ValidPassword, + Password = password, InviteToken = invite.Token }) - .StatusCodeShouldBeUnauthorized() - ); - } - - [Fact] - public async Task SignupShouldFailWhenUsingExistingAccountWithNoPasswordOrInvalidPassword() - { - 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); - - var problemDetails = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Random Name", - Email = email, - Password = null! - }) - .StatusCodeShouldBeUnprocessableEntity() - ); - - 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 = "Random Name", - Email = email, - Password = "invalidPass" - }) - .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); - } - [Fact] - public async Task GitHubAsync_WithConfiguredProvider_ReturnsToken() - { - // Arrange - const string code = "github-user"; + Assert.NotNull(result); + Assert.False(String.IsNullOrEmpty(result.Token)); - // Act - var result = await SendExternalLoginAsync("github", code); + await RefreshDataAsync(); - // Assert - await AssertExternalLoginAsync(result, "github", code); - } + 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); - [Fact] - public async Task GoogleAsync_WithConfiguredProvider_ReturnsToken() - { - // Arrange - const string code = "google-user"; + organization = await _organizationRepository.GetByIdAsync(organization.Id); + Assert.NotNull(organization); + Assert.Empty(organization.Invites); - // Act - var result = await SendExternalLoginAsync("google", code); + var token = await _tokenRepository.GetByIdAsync(result.Token); + Assert.NotNull(token); + Assert.Equal(user.Id, token.UserId); + Assert.Equal(TokenType.Authentication, token.Type); - // Assert - await AssertExternalLoginAsync(result, "google", code); + var mailQueue = GetService>() as InMemoryQueue; + Assert.NotNull(mailQueue); + Assert.Equal(0, (await mailQueue.GetQueueStatsAsync()).Enqueued); } [Fact] - public async Task LiveAsync_WithConfiguredProvider_ReturnsToken() + public async Task CancelResetPasswordAsync_WithNonJsonBody_ReturnsUnsupportedMediaType() { // Arrange - const string code = "live-user"; + const string token = "test-token"; // Act - var result = await SendExternalLoginAsync("live", code); + using var response = await SendRequestAsync(r => r + .Post() + .AppendPath($"auth/cancel-reset-password/{token}") + .Content("ignored", "text/plain") + .ExpectedStatus(HttpStatusCode.UnsupportedMediaType)); // Assert - await AssertExternalLoginAsync(result, "windowslive", code); + Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); } - [Fact] - public async Task PasswordLogin_MissingRemoteIpAddress_StillEnforcesUserLimit() + public async Task CancelResetPasswordClearsTokenAsync() { - // Arrange - using var client = _server.CreateClient(); - long originalTokenCount = (await _tokenRepository.CountAsync()).Total; - for (int attempt = 0; attempt < 5; attempt++) + const string email = "cancel-reset-password@exceptionless.io"; + var user = new User { - 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); - } + EmailAddress = email, + FullName = "Cancel Reset Password", + Roles = AuthorizationRoles.AllScopes + }; - // Act - using var response = await client.PostAsJsonAsync("api/v2/auth/login", - new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }, - TestCancellationToken); + user.MarkEmailAddressVerified(); + user.CreatePasswordResetToken(TimeProvider); + string token = user.PasswordResetToken!; + await _userRepository.AddAsync(user); - // Assert - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Equal(originalTokenCount, (await _tokenRepository.CountAsync()).Total); + await SendRequestAsync(r => r + .Post() + .AppendPath($"auth/cancel-reset-password/{token}") + .StatusCodeShouldBeOk() + ); + + var updatedUser = await _userRepository.GetByEmailAddressAsync(email); + Assert.NotNull(updatedUser); + Assert.Null(updatedUser.PasswordResetToken); + Assert.Equal(DateTime.MinValue, updatedUser.PasswordResetTokenExpiration); } - [Fact] - public async Task PasswordLogin_ThrottlingExpires_LogsInWithoutPasswordResetOrReactivation() + [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) { - // 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++) + _authOptions.EnableAccountCreation = false; + _authOptions.EnableActiveDirectoryAuth = enableAdAuth; + + if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) { - 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 provider = new TestDomainLoginProvider(); + email = provider.GetEmailAddressFromUsername(email); } - 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); + return SendRequestAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Test", + Email = email, + Password = password, + InviteToken = StringExtensions.GetNewToken() + }) + .StatusCodeShouldBeForbidden() + ); } [Theory] - [InlineData(false, HttpStatusCode.Unauthorized)] - [InlineData(true, HttpStatusCode.OK)] - public async Task ResetPassword_PreservesActiveState_OnlyActiveUsersCanLogIn(bool isActive, HttpStatusCode expectedStatus) + [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) { - // Arrange - var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); - Assert.NotNull(user); - user = user with { IsActive = isActive }; - user.CreatePasswordResetToken(TimeProvider); - Assert.NotNull(user.PasswordResetToken); - await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); - using var client = _server.CreateClient(); - const string newPassword = "Password2$"; + _authOptions.EnableAccountCreation = false; + _authOptions.EnableActiveDirectoryAuth = enableAdAuth; - // 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); + if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) + { + var provider = new TestDomainLoginProvider(); + email = provider.GetEmailAddressFromUsername(email); + } - // 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); + return SendRequestAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Test", + Email = email, + Password = password, + InviteToken = null + }) + .StatusCodeShouldBeForbidden() + ); } [Fact] - public async Task ResetPassword_MissingRemoteIpAddress_ClearsUserLoginAttempts() + public async Task CannotSignupWithoutPassword() { - // Arrange - var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); - Assert.NotNull(user); - user.CreatePasswordResetToken(TimeProvider); - Assert.NotNull(user.PasswordResetToken); - await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); - var authService = GetService(); - for (int failure = 0; failure < 5; failure++) - { - var loginAttempt = await authService.TryBeginLoginAsync(user.EmailAddress, "192.0.2.1", TestCancellationToken); - Assert.NotNull(loginAttempt); - await authService.RecordLoginFailureAsync(loginAttempt); - } - using var client = _server.CreateClient(); - - // Act - using var response = await client.PostAsJsonAsync("api/v2/auth/reset-password", - new ResetPasswordModel { PasswordResetToken = user.PasswordResetToken, Password = "Password2$" }, - GetService(), - TestCancellationToken); + var problemDetails = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "hello", + Email = "test@domain.com", + Password = null! + }) + .StatusCodeShouldBeUnprocessableEntity() + ); - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(await authService.TryBeginLoginAsync(user.EmailAddress, "192.0.2.1", TestCancellationToken)); + Assert.NotNull(problemDetails); + Assert.Single(problemDetails.Errors); + Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); } [Fact] - public async Task BasicPasswordLogin_ConcurrentValidRequests_DoNotConsumeFailureQuota() + public async Task ChangePasswordShouldFailWithCurrentPasswordAsync() { - // 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 => + const string email = "test6@exceptionless.io"; + const string password = "Test6 password"; + const string salt = "1234567890123456"; + string passwordHash = password.ToSaltedHash(salt); + + var user = new User { - await start.Task; - using var response = await client.GetAsync("api/v2/users/me", TestCancellationToken); - return response.StatusCode; - }).ToArray(); + EmailAddress = email, + Password = passwordHash, + Salt = salt, + FullName = "User 6", + Roles = AuthorizationRoles.AllScopes + }; + + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); + + var result = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = password, + }) + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(result); + Assert.NotEmpty(result.Token); + + 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); - // Act - start.SetResult(); - var results = await Task.WhenAll(requests); + var problemDetails = await SendRequestAsAsync(r => r + .Post() + .BasicAuthorization(email, password) + .AppendPath("auth/change-password") + .Content(new ChangePasswordModel + { + CurrentPassword = password, + Password = password + }) + .StatusCodeShouldBeUnprocessableEntity() + ); - // Assert - Assert.Contains(HttpStatusCode.OK, results); - Assert.All(results, statusCode => Assert.True(statusCode is HttpStatusCode.OK or HttpStatusCode.Unauthorized)); - using var next = await client.GetAsync("api/v2/users/me", TestCancellationToken); - Assert.Equal(HttpStatusCode.OK, next.StatusCode); + Assert.NotNull(problemDetails); + Assert.Single(problemDetails.Errors); + Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); + + Assert.NotNull(await _tokenRepository.GetByIdAsync(result.Token)); } [Fact] - public async Task BasicPasswordLogin_MissingRemoteIpAddress_ReturnsCurrentUser() + public async Task EmailAddressAvailabilityReturnsCreatedForExistingUserAsync() { - // 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 = "existing-email-check@exceptionless.io"; + var user = new User + { + EmailAddress = email, + FullName = "Existing Email Check", + Roles = AuthorizationRoles.AllScopes + }; - // Act - using var response = await client.SendAsync(request, TestCancellationToken); + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + await SendRequestAsync(r => r + .AppendPath($"auth/check-email-address/{email}") + .StatusCodeShouldBeCreated() + ); } [Fact] - public async Task PasswordLogin_FailuresThroughBasic_AreThrottled() + public Task EmailAddressAvailabilityReturnsNoContentForMissingUserAsync() { - // 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); + return SendRequestAsync(r => r + .AppendPath("auth/check-email-address/missing-email-check@exceptionless.io") + .StatusCodeShouldBeNoContent() + ); } [Fact] - public async Task BasicPasswordLogin_FailuresThroughLogin_AreThrottled() + public async Task FacebookAsync_WithConfiguredProvider_ReturnsToken() { // Arrange - for (int attempt = 0; attempt < 5; attempt++) - { - await SendRequestAsync(request => request - .Post() - .AppendPath("auth/login") - .Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = "wrong-password" }) - .StatusCodeShouldBeUnauthorized()); - } + const string code = "facebook-user"; // Act - var response = await SendRequestAsync(request => request - .BasicAuthorization(SampleDataService.TEST_USER_EMAIL, SampleDataService.TEST_USER_PASSWORD) - .AppendPath("users/me") - .StatusCodeShouldBeUnauthorized()); + var result = await SendExternalLoginAsync("facebook", code); // Assert - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + await AssertExternalLoginAsync(result, "facebook", code); } [Fact] - public async Task BasicPasswordLogin_AfterRepeatedFailures_IsThrottled() + public async Task ForgotPasswordCreatesResetTokenAsync() { - // Arrange - const string email = "basic-throttle-user@exceptionless.test"; - const string password = "Password1$"; - const string salt = "1234567890123456"; - + const string email = "forgot-password@exceptionless.io"; var user = new User { EmailAddress = email, - Password = password.ToSaltedHash(salt), - Salt = salt, - FullName = "Basic Throttle User", + FullName = "Forgot Password", Roles = AuthorizationRoles.AllScopes }; + user.MarkEmailAddressVerified(); await _userRepository.AddAsync(user); - for (int attempt = 0; attempt < 5; attempt++) - { - await SendRequestAsync(r => r - .BasicAuthorization(email, "wrong-password") - .AppendPath("users/me") - .StatusCodeShouldBeUnauthorized()); - } + await SendRequestAsync(r => r + .AppendPath($"auth/forgot-password/{email}") + .StatusCodeShouldBeOk() + ); - // Act - var response = await SendRequestAsync(r => r - .BasicAuthorization(email, password) - .AppendPath("users/me") - .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); + [Fact] + public Task ForgotPasswordForUnknownEmailReturnsOkAsync() + { + return SendRequestAsync(r => r + .AppendPath("auth/forgot-password/missing-password-user@exceptionless.io") + .StatusCodeShouldBeOk() + ); } - [Theory] - [InlineData("Basic !!!not-base64!!!")] - [InlineData("Basic")] - [InlineData("Basic ")] - [InlineData("Basic Og==")] - [InlineData("Basic ICA6cGFzc3dvcmQ=")] - public async Task BasicAuthentication_MalformedHeader_ReturnsUnauthorized(string authorization) + [Fact] + public async Task GetIntercomToken_WhenIntercomIsDisabled_ReturnsUnprocessableEntityAsync() { // Arrange - using var client = CreateHttpClient(); - using var request = new HttpRequestMessage(HttpMethod.Get, "users/me"); - request.Headers.TryAddWithoutValidation("Authorization", authorization); + _intercomOptions.IntercomSecret = null; // Act - using var response = await client.SendAsync(request, TestCancellationToken); + var problemDetails = await SendRequestAsAsync(r => r + .BearerToken(TestConstants.UserApiKey) + .AppendPath("auth/intercom") + .StatusCodeShouldBeUnprocessableEntity() + ); // Assert - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.NotNull(problemDetails); + Assert.True(problemDetails.Errors.TryGetValue("intercom", out string[]? intercomErrors)); + Assert.Contains("Intercom is not enabled.", intercomErrors); } - [Theory] - [InlineData("Basic")] - [InlineData("Basic !!!not-base64!!!")] - [InlineData("Basic Og==")] - public async Task BasicAuthentication_UnparseableCredentials_ReturnsNoResult(string authorization) + [Fact] + public Task GetIntercomToken_WhenUnauthenticated_ReturnsUnauthorizedAsync() { // 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); + _intercomOptions.IntercomSecret = "test-intercom-secret-with-adequate-length-12345"; // Act - var result = await context.AuthenticateAsync(ApiKeyAuthenticationOptions.ApiKeySchema); - - // Assert - Assert.True(result.None); - Assert.Null(result.Failure); - Assert.Null(result.Principal); + return SendRequestAsync(r => r + .AppendPath("auth/intercom") + .StatusCodeShouldBeUnauthorized() + ); } - [Theory] - [InlineData(null, "hash")] - [InlineData("", "hash")] - [InlineData("salt", null)] - [InlineData("salt", "")] - public async Task BasicPasswordLogin_MissingStoredCredentials_ReturnsUnauthorized(string? salt, string? passwordHash) + [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 = "missing-credentials@exceptionless.test", - FullName = "Missing Credentials", - Salt = salt, - Password = passwordHash, - Roles = new HashSet([AuthorizationRoles.Client, AuthorizationRoles.User]) + EmailAddress = email, + FullName = "Intercom User", + Password = password.ToSaltedHash(salt), + Roles = AuthorizationRoles.AllScopes, + Salt = salt }; - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user, options => options.ImmediateConsistency()); - - // Act - var response = await SendRequestAsync(request => request - .BasicAuthorization(user.EmailAddress, "Password1$") - .AppendPath("users/me") - .StatusCodeShouldBeUnauthorized()); - // Assert - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user, o => o.ImmediateConsistency()); - [Theory] - [InlineData("Basic", "client:{token}")] - [InlineData("Basic", "{token}:")] - [InlineData("bAsIc", "{token}:x-oauth-basic")] - [InlineData("Basic ", "client:{token}")] - public async Task BasicAuthentication_AuthenticationToken_ReturnsCurrentUser(string scheme, string credentialsTemplate) - { - // Arrange - var login = await SendRequestAsAsync(request => request + var authToken = await SendRequestAsAsync(r => r .Post() .AppendPath("auth/login") - .Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }) - .StatusCodeShouldBeOk()); - Assert.NotNull(login); - string credentials = credentialsTemplate.Replace("{token}", login.Token, StringComparison.Ordinal); - using var client = CreateHttpClient(); - using var request = new HttpRequestMessage(HttpMethod.Get, "users/me"); - request.Headers.TryAddWithoutValidation("Authorization", $"{scheme} {Convert.ToBase64String(Encoding.UTF8.GetBytes(credentials))}"); + .Content(new Login + { + Email = email, + Password = password + }) + .StatusCodeShouldBeOk() + ); + Assert.NotNull(authToken); // Act - using var response = await client.SendAsync(request, TestCancellationToken); + var intercomToken = await SendRequestAsAsync(r => r + .BearerToken(authToken.Token) + .AppendPath("auth/intercom") + .StatusCodeShouldBeOk() + ); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + 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 BasicAuthentication_PasswordContainingColons_ReturnsCurrentUser() + public async Task GitHubAsync_WithConfiguredProvider_ReturnsToken() { // 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()); + const string code = "github-user"; // Act - var response = await SendRequestAsync(request => request - .BasicAuthorization(user.EmailAddress, password).AppendPath("users/me").StatusCodeShouldBeOk()); + var result = await SendExternalLoginAsync("github", code); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + await AssertExternalLoginAsync(result, "github", code); } [Fact] - public async Task GitHubAsync_WithoutInviteAndAuthenticatedSession_LinksCurrentUser() + public async Task GitHubAsync_WithInvalidInviteAndAccountCreationDisabled_IsForbidden() { // Arrange - const string code = "github-linked-user"; - var currentUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); - Assert.NotNull(currentUser); + _authOptions.EnableAccountCreation = false; + const string code = "github-invited-user"; + string email = TestOAuthProviderClient.GetEmailAddress(code); // Act - var result = await SendRequestAsAsync(request => request + await SendRequestAsync(r => r .Post() - .AsTestOrganizationUser() .AppendPaths("auth", "github") .Content(new ExternalAuthInfo { ClientId = "client-id", Code = code, + InviteToken = StringExtensions.GetNewToken(), RedirectUri = "http://localhost/callback" }) - .StatusCodeShouldBeOk() + .StatusCodeShouldBeForbidden() ); // Assert - Assert.NotNull(result); - var token = await _tokenRepository.GetByIdAsync(result.Token); - Assert.NotNull(token); - Assert.Equal(currentUser.Id, token.UserId); - - currentUser = await _userRepository.GetByIdAsync(currentUser.Id); - Assert.NotNull(currentUser); - Assert.Contains(currentUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + Assert.Null(await _userRepository.GetByEmailAddressAsync(email)); } [Fact] @@ -1052,275 +1334,62 @@ public async Task GitHubAsync_WithValidInviteAndAuthenticatedSession_Authenticat } [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] - public async Task LoginValidAsync() - { - _authOptions.EnableActiveDirectoryAuth = false; - - 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); - - var result = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = email, - Password = password - }) - .StatusCodeShouldBeOk() - ); - - Assert.NotNull(result); - Assert.False(String.IsNullOrEmpty(result.Token)); - } - - [Fact] - public async Task RemoveExternalLoginAsync_WithLinkedAccount_RemovesAccount() + public async Task GitHubAsync_WithoutInviteAndAuthenticatedSession_LinksCurrentUser() { // 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()); + const string code = "github-linked-user"; + var currentUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(currentUser); // 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 + var result = await SendRequestAsAsync(request => request .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); - - var user = new User - { - EmailAddress = email, - Password = passwordHash, - Salt = salt, - FullName = "User 7" - }; - - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user); - - await SendRequestAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = email, - Password = "This password ain't right" - }) - .StatusCodeShouldBeUnauthorized() - ); - } - - [Fact] - public async Task LoginNoSuchUserAsync() - { - _authOptions.EnableActiveDirectoryAuth = false; - - 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, - Password = passwordHash, - Salt = salt, - FullName = "User 8" - }; - - 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() - ); - } - - [Fact] - public async Task LoginValidExistingActiveDirectoryAsync() - { - _authOptions.EnableActiveDirectoryAuth = true; - - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); - var user = new User - { - EmailAddress = email, - FullName = "User 6" - }; - - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user); - - var result = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = email, - Password = TestDomainLoginProvider.ValidPassword - }) - .StatusCodeShouldBeOk() - ); - - Assert.NotNull(result); - Assert.False(String.IsNullOrEmpty(result.Token)); - } - - [Fact] - public Task LoginValidNonExistentActiveDirectoryAsync() - { - _authOptions.EnableActiveDirectoryAuth = true; - - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); - - return SendRequestAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = email, - Password = TestDomainLoginProvider.ValidPassword - }) - .StatusCodeShouldBeUnauthorized() + .AppendPaths("auth", "github") + .Content(new ExternalAuthInfo + { + ClientId = "client-id", + Code = code, + RedirectUri = "http://localhost/callback" + }) + .StatusCodeShouldBeOk() ); + + // Assert + Assert.NotNull(result); + var token = await _tokenRepository.GetByIdAsync(result.Token); + Assert.NotNull(token); + Assert.Equal(currentUser.Id, token.UserId); + + currentUser = await _userRepository.GetByIdAsync(currentUser.Id); + Assert.NotNull(currentUser); + Assert.Contains(currentUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); } [Fact] - public async Task LoginInvalidNonExistentActiveDirectoryAsync() + public async Task GoogleAsync_WithConfiguredProvider_ReturnsToken() { - _authOptions.EnableActiveDirectoryAuth = true; - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); + // Arrange + const string code = "google-user"; - await SendRequestAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = $"{email}.au", - Password = "Totallywrongpassword1234" - }) - .StatusCodeShouldBeUnauthorized() - ); + // Act + var result = await SendExternalLoginAsync("google", code); - // Verify that a user account was not added - var user = await _userRepository.GetByEmailAddressAsync($"{email}.au"); - Assert.Null(user); + // Assert + await AssertExternalLoginAsync(result, "google", code); } [Fact] - public async Task LoginInvalidExistingActiveDirectoryAsync() + public async Task LiveAsync_WithConfiguredProvider_ReturnsToken() { - _authOptions.EnableActiveDirectoryAuth = true; - - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); - var user = new User - { - EmailAddress = email, - FullName = "User 6" - }; + // Arrange + const string code = "live-user"; - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user); + // Act + var result = await SendExternalLoginAsync("live", code); - await SendRequestAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = email, - Password = "Totallywrongpassword1234" - }) - .StatusCodeShouldBeUnauthorized() - ); + // Assert + await AssertExternalLoginAsync(result, "windowslive", code); } [Fact] @@ -1352,150 +1421,63 @@ await SendRequestAsync(r => r } [Fact] - public async Task CanChangePasswordAsync() + public async Task LoginInvalidExistingActiveDirectoryAsync() { - const string email = "test6@exceptionless.io"; - const string password = "Test6 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 6", - Roles = AuthorizationRoles.AllScopes + FullName = "User 6" }; user.MarkEmailAddressVerified(); await _userRepository.AddAsync(user); - var result = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = email, - Password = password, - }) - .StatusCodeShouldBeOk() - ); - - Assert.NotNull(result); - Assert.NotEmpty(result.Token); - - 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"; - var changePasswordResult = await SendRequestAsAsync(r => r - .Post() - .BasicAuthorization(email, password) - .AppendPath("auth/change-password") - .Content(new ChangePasswordModel - { - CurrentPassword = password, - Password = newPassword - }) - .StatusCodeShouldBeOk() + await SendRequestAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = "Totallywrongpassword1234" + }) + .StatusCodeShouldBeUnauthorized() ); - - 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 ChangePasswordShouldFailWithCurrentPasswordAsync() + public async Task LoginInvalidNonExistentActiveDirectoryAsync() { - 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(); - await _userRepository.AddAsync(user); - - var result = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = email, - Password = password, - }) - .StatusCodeShouldBeOk() - ); - - Assert.NotNull(result); - Assert.NotEmpty(result.Token); - - 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); + _authOptions.EnableActiveDirectoryAuth = true; + var provider = new TestDomainLoginProvider(); + string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); - var problemDetails = await SendRequestAsAsync(r => r - .Post() - .BasicAuthorization(email, password) - .AppendPath("auth/change-password") - .Content(new ChangePasswordModel - { - CurrentPassword = password, - Password = password - }) - .StatusCodeShouldBeUnprocessableEntity() + await SendRequestAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = $"{email}.au", + Password = "Totallywrongpassword1234" + }) + .StatusCodeShouldBeUnauthorized() ); - Assert.NotNull(problemDetails); - Assert.Single(problemDetails.Errors); - Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); - - Assert.NotNull(await _tokenRepository.GetByIdAsync(result.Token)); + // Verify that a user account was not added + var user = await _userRepository.GetByEmailAddressAsync($"{email}.au"); + Assert.Null(user); } [Fact] - public async Task CanResetPasswordAsync() + public async Task LoginInvalidPasswordAsync() { - const string email = "test6@exceptionless.io"; - const string password = "Test6 password"; + _authOptions.EnableActiveDirectoryAuth = false; + + const string email = "test7@exceptionless.io"; + const string password = "Test7 password"; const string salt = "1234567890123456"; string passwordHash = password.ToSaltedHash(salt); @@ -1504,250 +1486,266 @@ public async Task CanResetPasswordAsync() EmailAddress = email, Password = passwordHash, Salt = salt, - FullName = "User 6", - Roles = AuthorizationRoles.AllScopes + FullName = "User 7" }; 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() - .AppendPath("auth/login") - .Content(new Login - { - Email = email, - Password = password, - }) - .StatusCodeShouldBeOk() + await SendRequestAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = "This password ain't right" + }) + .StatusCodeShouldBeUnauthorized() ); + } - Assert.NotNull(result); - Assert.NotEmpty(result.Token); - - var token = await _tokenRepository.GetByIdAsync(result.Token); - Assert.NotNull(token); + [Fact] + public async Task LoginNoSuchUserAsync() + { + _authOptions.EnableActiveDirectoryAuth = false; - 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 + const string email = "test8@exceptionless.io"; + const string password = "Test8 password"; + const string salt = "1234567890123456"; + string passwordHash = password.ToSaltedHash(salt); + var user = new User { - 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()); + EmailAddress = email, + Password = passwordHash, + Salt = salt, + FullName = "User 8" + }; + + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); - const string newPassword = "NewP@ssword2"; await SendRequestAsync(r => r - .Post() - .BasicAuthorization(email, password) - .AppendPath("auth/reset-password") - .Content(new ResetPasswordModel - { - PasswordResetToken = user.PasswordResetToken, - Password = newPassword - }) - .StatusCodeShouldBeOk() + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = "Thisguydoesntexist@exceptionless.io", + Password = "This password ain't right" + }) + .StatusCodeShouldBeUnauthorized() ); - - 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 LoginValidAsync() { + _authOptions.EnableActiveDirectoryAuth = false; + 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 + FullName = "User 6" }; 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() - .AppendPath("auth/login") - .Content(new Login - { - Email = email, - Password = password, - }) - .StatusCodeShouldBeOk() + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = password + }) + .StatusCodeShouldBeOk() ); Assert.NotNull(result); - Assert.NotEmpty(result.Token); - - 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 problemDetails = await SendRequestAsAsync(r => r - .Post() - .BasicAuthorization(email, password) - .AppendPath("auth/reset-password") - .Content(new ResetPasswordModel - { - PasswordResetToken = user.PasswordResetToken, - Password = password - }) - .StatusCodeShouldBeUnprocessableEntity() - ); - - 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.False(String.IsNullOrEmpty(result.Token)); } [Fact] - public async Task ForgotPasswordCreatesResetTokenAsync() + public async Task LoginValidExistingActiveDirectoryAsync() { - const string email = "forgot-password@exceptionless.io"; + _authOptions.EnableActiveDirectoryAuth = true; + + var provider = new TestDomainLoginProvider(); + string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); var user = new User { EmailAddress = email, - FullName = "Forgot Password", - Roles = AuthorizationRoles.AllScopes + FullName = "User 6" }; user.MarkEmailAddressVerified(); await _userRepository.AddAsync(user); - await SendRequestAsync(r => r - .AppendPath($"auth/forgot-password/{email}") - .StatusCodeShouldBeOk() + var result = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = TestDomainLoginProvider.ValidPassword + }) + .StatusCodeShouldBeOk() ); - var updatedUser = await _userRepository.GetByEmailAddressAsync(email); - Assert.NotNull(updatedUser); - Assert.False(String.IsNullOrEmpty(updatedUser.PasswordResetToken)); - Assert.True(updatedUser.PasswordResetTokenExpiration.IsAfter(TimeProvider.GetUtcNow().UtcDateTime)); + Assert.NotNull(result); + Assert.False(String.IsNullOrEmpty(result.Token)); } [Fact] - public Task ForgotPasswordForUnknownEmailReturnsOkAsync() + public Task LoginValidNonExistentActiveDirectoryAsync() { + _authOptions.EnableActiveDirectoryAuth = true; + + var provider = new TestDomainLoginProvider(); + string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); + return SendRequestAsync(r => r - .AppendPath("auth/forgot-password/missing-password-user@exceptionless.io") - .StatusCodeShouldBeOk() + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = TestDomainLoginProvider.ValidPassword + }) + .StatusCodeShouldBeUnauthorized() ); } [Fact] - public async Task CancelResetPasswordAsync_WithNonJsonBody_ReturnsUnsupportedMediaType() + public async Task PasswordLogin_FailuresThroughBasic_AreThrottled() { // Arrange - const string token = "test-token"; + for (int attempt = 0; attempt < 5; attempt++) + { + await SendRequestAsync(request => request + .BasicAuthorization(SampleDataService.TEST_USER_EMAIL, "wrong-password") + .AppendPath("users/me") + .StatusCodeShouldBeUnauthorized()); + } // Act - using var response = await SendRequestAsync(r => r + var response = await SendRequestAsync(request => request .Post() - .AppendPath($"auth/cancel-reset-password/{token}") - .Content("ignored", "text/plain") - .ExpectedStatus(HttpStatusCode.UnsupportedMediaType)); + .AppendPath("auth/login") + .Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }) + .StatusCodeShouldBeUnauthorized()); // Assert - Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task CancelResetPasswordClearsTokenAsync() + public async Task PasswordLogin_MissingRemoteIpAddress_StillEnforcesUserLimit() { - const string email = "cancel-reset-password@exceptionless.io"; - var user = new User + // Arrange + using var client = _server.CreateClient(); + long originalTokenCount = (await _tokenRepository.CountAsync()).Total; + for (int attempt = 0; attempt < 5; attempt++) { - EmailAddress = email, - FullName = "Cancel Reset Password", - Roles = AuthorizationRoles.AllScopes - }; - - user.MarkEmailAddressVerified(); - user.CreatePasswordResetToken(TimeProvider); - string token = user.PasswordResetToken!; - await _userRepository.AddAsync(user); + 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); + } - await SendRequestAsync(r => r - .Post() - .AppendPath($"auth/cancel-reset-password/{token}") - .StatusCodeShouldBeOk() - ); + // Act + using var response = await client.PostAsJsonAsync("api/v2/auth/login", + new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }, + TestCancellationToken); - 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); + Assert.Equal(originalTokenCount, (await _tokenRepository.CountAsync()).Total); } [Fact] - public async Task EmailAddressAvailabilityReturnsCreatedForExistingUserAsync() + public async Task PasswordLogin_ThrottlingExpires_LogsInWithoutPasswordResetOrReactivation() { - const string email = "existing-email-check@exceptionless.io"; - var user = new User + // 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++) { - EmailAddress = email, - FullName = "Existing Email Check", - Roles = AuthorizationRoles.AllScopes - }; + 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 }; - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user); + // 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()); - await SendRequestAsync(r => r - .AppendPath($"auth/check-email-address/{email}") - .StatusCodeShouldBeCreated() + // 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 EmailAddressAvailabilityReturnsNoContentForMissingUserAsync() + public Task RemoveExternalLoginAsync_WithoutProviderUserId_ReturnsBadRequest() { + // Arrange + var providerUserId = new ValueFromBody(String.Empty); + + // Act & Assert return SendRequestAsync(r => r - .AppendPath("auth/check-email-address/missing-email-check@exceptionless.io") - .StatusCodeShouldBeNoContent() + .Post() + .AsTestOrganizationUser() + .AppendPaths("auth", "unlink", "github") + .Content(providerUserId) + .StatusCodeShouldBeBadRequest() ); } [Fact] - public async Task CanLogoutUserAsync() + public async Task ResetPasswordShouldFailWithCurrentPasswordAsync() { - const string email = "test7@exceptionless.io"; - const string password = "Test7 password"; + const string email = "test6@exceptionless.io"; + const string password = "Test6 password"; const string salt = "1234567890123456"; string passwordHash = password.ToSaltedHash(salt); @@ -1756,11 +1754,15 @@ public async Task CanLogoutUserAsync() EmailAddress = email, Password = passwordHash, Salt = salt, - FullName = "User 7", + 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 @@ -1775,150 +1777,147 @@ public async Task CanLogoutUserAsync() ); 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); - await SendRequestAsync(r => r - .BearerToken(result.Token) - .AppendPath("auth/logout") - .StatusCodeShouldBeOk() + Assert.NotNull(token.UserId); + var actualUser = await _userRepository.GetByIdAsync(token.UserId); + Assert.NotNull(actualUser); + Assert.Equal(email, actualUser.EmailAddress); + + var problemDetails = await SendRequestAsAsync(r => r + .Post() + .BasicAuthorization(email, password) + .AppendPath("auth/reset-password") + .Content(new ResetPasswordModel + { + PasswordResetToken = user.PasswordResetToken, + Password = password + }) + .StatusCodeShouldBeUnprocessableEntity() ); - token = await _tokenRepository.GetByIdAsync(result.Token); - Assert.Null(token); + Assert.NotNull(problemDetails); + Assert.Single(problemDetails.Errors); + Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); + + Assert.NotNull(await _tokenRepository.GetByIdAsync(result.Token)); } [Fact] - public async Task CanLogoutUserAccessTokenAsync() + public async Task ResetPassword_MissingRemoteIpAddress_ClearsUserLoginAttempts() { - var token = await _tokenRepository.GetByIdAsync(TestConstants.UserApiKey); - Assert.NotNull(token); - Assert.Equal(TokenType.Access, token.Type); - Assert.False(token.IsDisabled); - Assert.False(token.IsSuspended); + // 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++) + { + 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(); - await SendRequestAsync(r => r - .BearerToken(token.Id) - .AppendPath("auth/logout") - .StatusCodeShouldBeForbidden() - ); + // 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); - token = (await _tokenRepository.GetByIdAsync(token.Id))!; - Assert.NotNull(token); - Assert.Equal(TokenType.Access, token.Type); - Assert.False(token.IsDisabled); - Assert.False(token.IsSuspended); + // 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); + await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); + using var client = _server.CreateClient(); + const string newPassword = "Password2$"; + + // Act + using var reset = await client.PostAsJsonAsync("api/v2/auth/reset-password", + new ResetPasswordModel { PasswordResetToken = user.PasswordResetToken, Password = newPassword }, + GetService(), TestCancellationToken); + var storedUser = await _userRepository.GetByIdAsync(user.Id, options => options.ImmediateConsistency()); + using var login = await client.PostAsJsonAsync("api/v2/auth/login", + new Login { Email = user.EmailAddress, Password = newPassword }, TestCancellationToken); + using var basicRequest = new HttpRequestMessage(HttpMethod.Get, "api/v2/users/me"); + basicRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user.EmailAddress}:{newPassword}"))); + using var basicLogin = await client.SendAsync(basicRequest, TestCancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, reset.StatusCode); + Assert.NotNull(storedUser); + Assert.Equal(isActive, storedUser.IsActive); + Assert.True(storedUser.IsCorrectPassword(newPassword)); + Assert.Equal(expectedStatus, login.StatusCode); + Assert.Equal(expectedStatus, basicLogin.StatusCode); + } [Fact] - public async Task GetIntercomToken_WithValidAuthenticatedUser_ReturnsJwtAsync() + public async Task SignupShouldFailWhenUsingExistingAccountWithNoPasswordOrInvalidPassword() { - // 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 + 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() - ); - 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 Task GetIntercomToken_WhenUnauthenticated_ReturnsUnauthorizedAsync() - { - // Arrange - _intercomOptions.IntercomSecret = "test-intercom-secret-with-adequate-length-12345"; - - // Act - return SendRequestAsync(r => r - .AppendPath("auth/intercom") - .StatusCodeShouldBeUnauthorized() - ); - } - - [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 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); + Assert.Single(problemDetails.Errors); + Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); await SendRequestAsync(r => r - .BearerToken(token.Id) - .AppendPath("auth/logout") - .StatusCodeShouldBeForbidden() + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Random Name", + Email = email, + Password = "invalidPass" + }) + .StatusCodeShouldBeUnauthorized() ); - - token = (await _tokenRepository.GetByIdAsync(token.Id))!; - Assert.NotNull(token); - Assert.Equal(TokenType.Access, token.Type); - Assert.False(token.IsDisabled); - Assert.False(token.IsSuspended); } private async Task AssertExternalLoginAsync(TokenResult? result, string providerName, string providerUserId) diff --git a/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs index d3b414c007..0ce06509e5 100644 --- a/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs +++ b/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs @@ -7,32 +7,6 @@ namespace Exceptionless.Tests.Extensions; public sealed class HttpExtensionsTests { - [Theory] - [InlineData("Basic", "user@example.com", "password")] - [InlineData("bAsIc", "user@example.com", "password")] - [InlineData("Basic ", "user@example.com", "password")] - [InlineData("Basic", " user@example.com ", " password ")] - [InlineData("Basic", "user@example.com", "pässwörd")] - [InlineData("Basic", "user@example.com", "pass:word:with:colons")] - [InlineData("Basic", "api-token", "")] - [InlineData("Basic", "client", "api-token")] - [InlineData("Basic", "api-token", "x-oauth-basic")] - public void GetBasicAuth_ValidCredentials_PreservesUsernameAndPassword(string scheme, string username, string password) - { - // Arrange - var request = new DefaultHttpContext().Request; - string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); - request.Headers.Authorization = $"{scheme} {encoded} "; - - // Act - var credentials = request.GetBasicAuth(); - - // Assert - Assert.NotNull(credentials); - Assert.Equal(username, credentials.Username); - Assert.Equal(password, credentials.Password); - } - [Theory] [InlineData(null)] [InlineData("")] @@ -62,10 +36,40 @@ public void GetBasicAuth_InvalidHeader_ReturnsNull(string? authorization) [Fact] public void GetBasicAuth_NullRequest_ThrowsArgumentNullException() { + // Arrange + HttpRequest request = null!; + // Act - var exception = Assert.Throws(() => HttpExtensions.GetBasicAuth(null!)); + var exception = Assert.Throws(() => HttpExtensions.GetBasicAuth(request)); // Assert Assert.Equal("request", 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/Services/AuthServiceTests.cs b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs index 22c1fda5d1..df55e10301 100644 --- a/tests/Exceptionless.Tests/Services/AuthServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs @@ -1,3 +1,5 @@ +using System.Reflection; +using System.Runtime.ExceptionServices; using Exceptionless.Core.Services; using Foundatio.Caching; using Xunit; @@ -7,125 +9,231 @@ namespace Exceptionless.Tests.Services; public sealed class AuthServiceTests(ITestOutputHelper output) : TestWithServices(output) { [Fact] - public async Task TryBeginLoginAsync_ConcurrentInstances_BoundsChecksBeforeFailuresComplete() - { - var first = GetService(); - var second = new AuthService(GetService(), TimeProvider); - var attempts = await Task.WhenAll(Enumerable.Range(0, 100).Select(i => - (i % 2 == 0 ? first : second).TryBeginLoginAsync(" User@exceptionless.test ", null, TestCancellationToken))); - Assert.Equal(5, attempts.Count(a => a is not null)); - await Task.WhenAll(attempts.Where(a => a is not null).Select(a => first.RecordLoginFailureAsync(a!))); - Assert.Null(await second.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken)); - } - - [Fact] - public async Task TryBeginLoginAsync_ConcurrentUsers_BoundsChecksAtSharedIpAddress() + public async Task ClearUserLoginAttemptsAsync_Recovery_PreservesIpFailuresAndChecksUnderway() { + // Arrange var service = GetService(); - var attempts = await Task.WhenAll(Enumerable.Range(0, 100).Select(i => - service.TryBeginLoginAsync($"user{i}@exceptionless.test", "192.0.2.1", TestCancellationToken))); - Assert.Equal(15, attempts.Count(a => a is not null)); - await Task.WhenAll(attempts.Where(a => a is not null).Select(a => service.RecordLoginFailureAsync(a!))); - Assert.Null(await service.TryBeginLoginAsync("other@exceptionless.test", "192.0.2.1", TestCancellationToken)); - // Rejected IP admission must release the partially reserved account slot. - await using var allowed = await service.TryBeginLoginAsync("user99@exceptionless.test", "192.0.2.2", TestCancellationToken); - Assert.NotNull(allowed); + 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 async Task RecordLoginSuccessAsync_ValidRequests_DoNotConsumeFailureQuota() + public async Task DisposeAsync_CompletedFailure_RetainsCharge() { + // Arrange var service = GetService(); - 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 Task.WhenAll(attempts.Select(a => a.DisposeAsync().AsTask())); - } - await using var next = await BeginAsync(service); + 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 BeginAsync(service); + + await using var next = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.NotNull(next); } [Fact] - public async Task DisposeAsync_CompletedFailure_RetainsCharge() + public async Task RecordLoginAsync_NullAttempt_Throws() { + // Arrange var service = GetService(); - for (int i = 0; i < 5; i++) - { - await using var attempt = await BeginAsync(service); - await service.RecordLoginFailureAsync(attempt); - } - Assert.Null(await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken)); + + // Act + var failureException = await Record.ExceptionAsync(() => service.RecordLoginFailureAsync(null!)); + var successException = await Record.ExceptionAsync(() => service.RecordLoginSuccessAsync(null!)); + var cacheException = Record.Exception(() => new AuthService(null!, TimeProvider)); + var timeException = Record.Exception(() => new AuthService(GetService(), null!)); + + // Assert + Assert.IsType(failureException); + Assert.IsType(successException); + Assert.IsType(cacheException); + Assert.IsType(timeException); } [Fact] - public async Task RecordLoginSuccessAsync_PreservesNewFailuresAndOtherReservations() + public async Task RecordLoginSuccessAsync_ConcurrentFailures_PreservesNewFailuresAndOtherReservations() { + // Arrange var service = GetService(); await FailAsync(service); - var success = await BeginAsync(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); - Assert.Null(await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken)); + var denied = await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken); + + // Assert + Assert.Null(denied); } [Fact] - public async Task RecordLoginSuccessAsync_DoesNotRefundOtherUsersIpFailures() + 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 service.RecordLoginSuccessAsync(await BeginAsync(service)); + + await using var success = await BeginAsync(service); + + // Act + await service.RecordLoginSuccessAsync(success); await FailAsync(service, "last@exceptionless.test"); - Assert.Null(await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken)); + var denied = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + + // Assert + Assert.Null(denied); } [Fact] - public async Task ClearUserLoginAttemptsAsync_PreservesIpFailuresAndChecksUnderway() + public async Task RecordLoginSuccessAsync_ValidRequests_DoNotConsumeFailureQuota() { + // Arrange var service = GetService(); - for (int i = 0; i < 4; i++) - await FailAsync(service); - var pending = await BeginAsync(service); - await service.ClearUserLoginAttemptsAsync(" User@exceptionless.test "); - var remaining = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken))); - Assert.Equal(4, remaining.Count(a => a is not null)); - await pending.DisposeAsync(); - await Task.WhenAll(remaining.Where(a => a is not null).Select(a => a!.DisposeAsync().AsTask())); - for (int i = 0; i < 11; i++) - await FailAsync(service, $"other{i}@exceptionless.test"); - Assert.Null(await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken)); + + // 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); + var cache = CacheCallProxy.Create(GetService(), out var proxy); + string? ipAddress = includeIpAddress ? "192.0.2.1" : null; + proxy.AfterAdd = cacheKey => + { + if (cacheKey.Contains(includeIpAddress ? "ip:" : "user:", StringComparison.Ordinal)) + cancellation.Cancel(); + }; + + var service = new AuthService(cache, TimeProvider); + + // Act + var exception = await Record.ExceptionAsync(async () => + { + _ = await service.TryBeginLoginAsync("user@exceptionless.test", ipAddress, cancellation.Token); + }); + proxy.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_QuarterHour_ExpiresFailuresAndAbandonedReservations() + public async Task TryBeginLoginAsync_CancelledRequest_Throws() { - TimeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 14, 0, TimeSpan.Zero)); + // Arrange + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); + await cancellation.CancelAsync(); var service = GetService(); - var old = await BeginAsync(service); - for (int i = 0; i < 4; i++) - await FailAsync(service); - Assert.Null(await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken)); - TimeProvider.Advance(TimeSpan.FromMinutes(1)); - for (int i = 0; i < 5; i++) - await FailAsync(service); - await service.RecordLoginSuccessAsync(old); - await service.RecordLoginFailureAsync(old); - Assert.Null(await service.TryBeginLoginAsync("user@exceptionless.test", null, TestCancellationToken)); - TimeProvider.Advance(TimeSpan.FromMinutes(15)); - await using var next = await BeginAsync(service); + + // Act + var exception = await Record.ExceptionAsync(async () => + { + _ = await service.TryBeginLoginAsync("user@example.test", null, cancellation.Token); + }); + + // Assert + Assert.IsType(exception); + } + + [Fact] + public async Task TryBeginLoginAsync_ConcurrentInstances_BoundsChecksBeforeFailuresComplete() + { + // Arrange + var first = GetService(); + var second = new AuthService(GetService(), TimeProvider); + + // 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] @@ -134,45 +242,162 @@ public async Task TryBeginLoginAsync_QuarterHour_ExpiresFailuresAndAbandonedRese [InlineData(" ")] public async Task TryBeginLoginAsync_InvalidEmail_Throws(string? email) { + // Arrange var service = GetService(); - await Assert.ThrowsAnyAsync(() => service.TryBeginLoginAsync(email!, null, TestCancellationToken)); - await Assert.ThrowsAnyAsync(() => service.ClearUserLoginAttemptsAsync(email!)); + + // 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 Task TryBeginLoginAsync_InvalidIpAddress_Throws(string address) - => Assert.ThrowsAnyAsync(() => GetService().TryBeginLoginAsync("user@example.test", address, TestCancellationToken)); + 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_CancelledRequest_Throws() + public async Task TryBeginLoginAsync_IpCacheFailure_ReleasesUserCacheKey() { - using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); - await cancellation.CancelAsync(); - await Assert.ThrowsAsync(() => GetService().TryBeginLoginAsync("user@example.test", null, cancellation.Token)); + // Arrange + var cache = CacheCallProxy.Create(GetService(), out var proxy); + var failure = new InvalidOperationException("Synthetic cache failure."); + proxy.BeforeAdd = cacheKey => + { + if (cacheKey.Contains("ip:", StringComparison.Ordinal)) + throw failure; + }; + + var service = new AuthService(cache, TimeProvider); + + // Act + var exception = await Record.ExceptionAsync(async () => + { + _ = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); + }); + proxy.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 RecordLoginAsync_NullAttempt_Throws() + public async Task TryBeginLoginAsync_QuarterHour_ExpiresFailuresAndAbandonedReservations() { + // Arrange + TimeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 14, 0, TimeSpan.Zero)); var service = GetService(); - await Assert.ThrowsAsync(() => service.RecordLoginFailureAsync(null!)); - await Assert.ThrowsAsync(() => service.RecordLoginSuccessAsync(null!)); - Assert.Throws(() => new AuthService(null!, TimeProvider)); - Assert.Throws(() => new AuthService(GetService(), null!)); + 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); } + + public class CacheCallProxy : DispatchProxy + { + private ICacheClient _inner = null!; + public Action? BeforeAdd { get; set; } + public Action? AfterAdd { get; set; } + + public static ICacheClient Create(ICacheClient inner, out CacheCallProxy proxy) + { + var cache = Create(); + proxy = (CacheCallProxy)cache; + proxy._inner = inner; + + return cache; + } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod!.Name == nameof(ICacheClient.AddAsync)) + { + string cacheKey = (string)args![0]!; + BeforeAdd?.Invoke(cacheKey); + + return AddAsync(targetMethod, args, cacheKey); + } + + return InvokeInner(targetMethod, args); + } + + private async Task AddAsync(MethodInfo method, object?[] args, string cacheKey) + { + bool added = await (Task)InvokeInner(method, args)!; + if (added) + AfterAdd?.Invoke(cacheKey); + + return added; + } + + private object? InvokeInner(MethodInfo method, object?[]? args) + { + try + { + return method.Invoke(_inner, args); + } + catch (TargetInvocationException exception) when (exception.InnerException is not null) + { + ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); + throw; + } + } + } } From 4671f68becfa8a2a787307d232623d30e209d398 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 4 Oct 2026 19:47:25 -0500 Subject: [PATCH 3/9] Preserve login admission errors when cleanup fails --- .../Services/AuthService.cs | 23 +++- .../Exceptionless.Tests/AppWebHostFactory.cs | 13 +- .../Services/AuthServiceTests.cs | 127 ++++++++++++------ tests/http/users.http | 8 -- 4 files changed, 108 insertions(+), 63 deletions(-) diff --git a/src/Exceptionless.Core/Services/AuthService.cs b/src/Exceptionless.Core/Services/AuthService.cs index dcbfde321e..488155f07e 100644 --- a/src/Exceptionless.Core/Services/AuthService.cs +++ b/src/Exceptionless.Core/Services/AuthService.cs @@ -1,5 +1,7 @@ using Exceptionless.DateTimeExtensions; using Foundatio.Caching; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Exceptionless.Core.Services; @@ -12,15 +14,23 @@ public sealed class AuthService private const int IpAddressFailureLimit = 15; private static readonly TimeSpan AttemptWindow = TimeSpan.FromMinutes(15); private readonly ScopedCacheClient _cache; + private readonly ILogger _logger; private readonly TimeProvider _timeProvider; public AuthService(ICacheClient cacheClient, TimeProvider timeProvider) + : this(cacheClient, timeProvider, NullLogger.Instance) + { + } + + public AuthService(ICacheClient cacheClient, TimeProvider timeProvider, ILogger logger) { ArgumentNullException.ThrowIfNull(cacheClient); ArgumentNullException.ThrowIfNull(timeProvider); + ArgumentNullException.ThrowIfNull(logger); _cache = new ScopedCacheClient(cacheClient, "Auth"); _timeProvider = timeProvider; + _logger = logger; } public async Task TryBeginLoginAsync(string emailAddress, string? ipAddress, CancellationToken cancellationToken = default) @@ -63,9 +73,18 @@ public AuthService(ICacheClient cacheClient, TimeProvider timeProvider) return new LoginAttempt(this, expiresUtc, reservedCacheKeys.ToArray(), reservation, observedFailures); } - catch + catch (Exception exception) { - await ReleaseCacheKeysAsync(reservedCacheKeys, reservation); + try + { + await ReleaseCacheKeysAsync(reservedCacheKeys, reservation); + } + catch (Exception cleanupException) + { + _logger.LogError("Failed to release login admission reservations after {FailureType}: {CleanupFailureType}", + exception.GetType().Name, cleanupException.GetType().Name); + } + throw; } } diff --git a/tests/Exceptionless.Tests/AppWebHostFactory.cs b/tests/Exceptionless.Tests/AppWebHostFactory.cs index c11b2a10fe..87edc24c4f 100644 --- a/tests/Exceptionless.Tests/AppWebHostFactory.cs +++ b/tests/Exceptionless.Tests/AppWebHostFactory.cs @@ -21,7 +21,7 @@ namespace Exceptionless.Tests; public class AppWebHostFactory : WebApplicationFactory, IAsyncLifetime { - private static readonly string SharedElasticsearchUrl = GetElasticsearchUrl(); + private const string SharedElasticsearchUrl = "http://localhost:9200"; private static readonly TimeSpan SharedElasticsearchStartupTimeout = TimeSpan.FromMinutes(3); private static int s_counter = -1; private static readonly Lazy> s_sharedAppHost = new(StartSharedAppHostAsync, LazyThreadSafetyMode.ExecutionAndPublication); @@ -43,19 +43,10 @@ public AppWebHostFactory() public async ValueTask InitializeAsync() { - if (String.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("EX_TestElasticsearchUrl"))) - _ = await s_sharedAppHost.Value; + _ = await s_sharedAppHost.Value; await WaitForElasticsearchAsync(new Uri(SharedElasticsearchUrl)); } - private static string GetElasticsearchUrl() - { - string url = Environment.GetEnvironmentVariable("EX_TestElasticsearchUrl") ?? "http://localhost:9200"; - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || !uri.IsLoopback || uri.Scheme != Uri.UriSchemeHttp) - throw new InvalidOperationException("Test Elasticsearch must use a local HTTP endpoint."); - return url; - } - private static async Task StartSharedAppHostAsync() { var appHost = await DistributedApplicationTestingBuilder.CreateAsync( diff --git a/tests/Exceptionless.Tests/Services/AuthServiceTests.cs b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs index df55e10301..c321fa6958 100644 --- a/tests/Exceptionless.Tests/Services/AuthServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs @@ -1,7 +1,6 @@ -using System.Reflection; -using System.Runtime.ExceptionServices; using Exceptionless.Core.Services; using Foundatio.Caching; +using Microsoft.Extensions.Logging; using Xunit; namespace Exceptionless.Tests.Services; @@ -151,9 +150,9 @@ public async Task TryBeginLoginAsync_CancelledAfterReservation_ReleasesBothCache { // Arrange using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); - var cache = CacheCallProxy.Create(GetService(), out var proxy); + using var cache = new FaultingCacheClient(TimeProvider); string? ipAddress = includeIpAddress ? "192.0.2.1" : null; - proxy.AfterAdd = cacheKey => + cache.AfterAdd = cacheKey => { if (cacheKey.Contains(includeIpAddress ? "ip:" : "user:", StringComparison.Ordinal)) cancellation.Cancel(); @@ -166,7 +165,7 @@ public async Task TryBeginLoginAsync_CancelledAfterReservation_ReleasesBothCache { _ = await service.TryBeginLoginAsync("user@exceptionless.test", ipAddress, cancellation.Token); }); - proxy.AfterAdd = null; + 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)); @@ -195,6 +194,64 @@ public async Task TryBeginLoginAsync_CancelledRequest_Throws() Assert.IsType(exception); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TryBeginLoginAsync_CleanupFailure_PreservesOriginalExceptionAndLogsSafeDetails(bool cancelled) + { + // 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 sensitive acquisition detail."); + cache.BeforeAdd = cacheKey => + { + if (!cancelled && cacheKey.Contains("ip:", StringComparison.Ordinal)) + throw failure; + }; + + cache.AfterAdd = cacheKey => + { + if (cancelled && cacheKey.Contains("ip:", StringComparison.Ordinal)) + cancellation.Cancel(); + }; + + cache.BeforeRemove = _ => throw new IOException("Synthetic sensitive cleanup detail."); + 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); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Error, entry.Level); + Assert.Null(entry.Exception); + Assert.Contains(cancelled ? nameof(OperationCanceledException) : nameof(InvalidOperationException), entry.Message); + Assert.Contains(nameof(IOException), entry.Message); + Assert.DoesNotContain("sensitive", entry.Message); + Assert.DoesNotContain("user@exceptionless.test", entry.Message); + Assert.DoesNotContain("192.0.2.1", entry.Message); + Assert.Equal(4, beforeExpiration.Count(attempt => attempt is not null)); + Assert.All(afterExpiration, Assert.NotNull); + } + [Fact] public async Task TryBeginLoginAsync_ConcurrentInstances_BoundsChecksBeforeFailuresComplete() { @@ -279,9 +336,9 @@ public async Task TryBeginLoginAsync_InvalidIpAddress_Throws(string address) public async Task TryBeginLoginAsync_IpCacheFailure_ReleasesUserCacheKey() { // Arrange - var cache = CacheCallProxy.Create(GetService(), out var proxy); + using var cache = new FaultingCacheClient(TimeProvider); var failure = new InvalidOperationException("Synthetic cache failure."); - proxy.BeforeAdd = cacheKey => + cache.BeforeAdd = cacheKey => { if (cacheKey.Contains("ip:", StringComparison.Ordinal)) throw failure; @@ -294,7 +351,7 @@ public async Task TryBeginLoginAsync_IpCacheFailure_ReleasesUserCacheKey() { _ = await service.TryBeginLoginAsync("user@exceptionless.test", "192.0.2.1", TestCancellationToken); }); - proxy.BeforeAdd = null; + 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); @@ -350,54 +407,40 @@ private async Task FailAsync(AuthService service, string email = "user@exception await service.RecordLoginFailureAsync(attempt); } - public class CacheCallProxy : DispatchProxy + private sealed class CapturingLogger : ILogger { - private ICacheClient _inner = null!; - public Action? BeforeAdd { get; set; } - public Action? AfterAdd { get; set; } - - public static ICacheClient Create(ICacheClient inner, out CacheCallProxy proxy) - { - var cache = Create(); - proxy = (CacheCallProxy)cache; - proxy._inner = inner; + public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = []; - return cache; - } + public IDisposable? BeginScope(TState state) where TState : notnull => null; - protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) - { - if (targetMethod!.Name == nameof(ICacheClient.AddAsync)) - { - string cacheKey = (string)args![0]!; - BeforeAdd?.Invoke(cacheKey); + public bool IsEnabled(LogLevel logLevel) => true; - return AddAsync(targetMethod, args, cacheKey); - } + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => Entries.Add((logLevel, formatter(state, exception), exception)); + } - return InvokeInner(targetMethod, args); - } + private sealed class FaultingCacheClient(TimeProvider timeProvider) : InMemoryCacheClient(options => options.TimeProvider(timeProvider)), ICacheClient + { + public Action? BeforeAdd { get; set; } + public Action? AfterAdd { get; set; } + public Action? BeforeRemove { get; set; } - private async Task AddAsync(MethodInfo method, object?[] args, string cacheKey) + async Task ICacheClient.AddAsync(string cacheKey, T value, TimeSpan? expiresIn) { - bool added = await (Task)InvokeInner(method, args)!; + BeforeAdd?.Invoke(cacheKey); + + bool added = await base.AddAsync(cacheKey, value, expiresIn); if (added) AfterAdd?.Invoke(cacheKey); return added; } - private object? InvokeInner(MethodInfo method, object?[]? args) + Task ICacheClient.RemoveIfEqualAsync(string cacheKey, T expected) { - try - { - return method.Invoke(_inner, args); - } - catch (TargetInvocationException exception) when (exception.InnerException is not null) - { - ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); - throw; - } + BeforeRemove?.Invoke(cacheKey); + + return base.RemoveIfEqualAsync(cacheKey, expected); } } } diff --git a/tests/http/users.http b/tests/http/users.http index 563b8a6efc..b996b9ee8f 100644 --- a/tests/http/users.http +++ b/tests/http/users.http @@ -16,14 +16,6 @@ Content-Type: application/json @token = {{login.response.body.$.token}} -### Password authentication shares the login attempt window -GET {{apiUrl}}/users/me -Authorization: Basic {{email}}:{{password}} - -### Authentication token through the client alias -GET {{apiUrl}}/users/me -Authorization: Basic client:{{token}} - ### Get Current User # @name currentUser GET {{apiUrl}}/users/me From 3321b1e849831fd241dfcdc5e0193486e9200e0f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 4 Oct 2026 20:56:00 -0500 Subject: [PATCH 4/9] Refine login admission cleanup and authentication tests --- .../Services/AuthService.cs | 105 +- .../Security/ApiKeyAuthenticationHandler.cs | 65 +- .../Api/Endpoints/AuthEndpointTests.cs | 2213 +++++++++-------- .../Extensions/HttpExtensionsTests.cs | 4 +- .../Services/AuthServiceTests.cs | 105 +- 5 files changed, 1387 insertions(+), 1105 deletions(-) diff --git a/src/Exceptionless.Core/Services/AuthService.cs b/src/Exceptionless.Core/Services/AuthService.cs index 488155f07e..82313811d2 100644 --- a/src/Exceptionless.Core/Services/AuthService.cs +++ b/src/Exceptionless.Core/Services/AuthService.cs @@ -1,7 +1,7 @@ +using System.Runtime.ExceptionServices; using Exceptionless.DateTimeExtensions; using Foundatio.Caching; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; namespace Exceptionless.Core.Services; @@ -17,11 +17,6 @@ public sealed class AuthService private readonly ILogger _logger; private readonly TimeProvider _timeProvider; - public AuthService(ICacheClient cacheClient, TimeProvider timeProvider) - : this(cacheClient, timeProvider, NullLogger.Instance) - { - } - public AuthService(ICacheClient cacheClient, TimeProvider timeProvider, ILogger logger) { ArgumentNullException.ThrowIfNull(cacheClient); @@ -43,7 +38,7 @@ public AuthService(ICacheClient cacheClient, TimeProvider timeProvider, ILogger< cancellationToken.ThrowIfCancellationRequested(); var expiresUtc = GetWindowExpiration(); - string[] userCacheKeys = GetCacheKeys($"user:{emailAddress.Trim().ToLowerInvariant()}", UserFailureLimit, expiresUtc); + 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(); @@ -59,7 +54,7 @@ public AuthService(ICacheClient cacheClient, TimeProvider timeProvider, ILogger< if (ipAddress is not null) { - string? ipAddressCacheKey = await ReserveCacheKeyAsync(GetCacheKeys($"ip:{ipAddress}", IpAddressFailureLimit, expiresUtc), reservation, expiresUtc); + string? ipAddressCacheKey = await ReserveCacheKeyAsync(GetIpAddressCacheKeys(ipAddress, expiresUtc), reservation, expiresUtc); if (ipAddressCacheKey is null) { await ReleaseCacheKeysAsync(reservedCacheKeys, reservation); @@ -71,19 +66,13 @@ public AuthService(ICacheClient cacheClient, TimeProvider timeProvider, ILogger< cancellationToken.ThrowIfCancellationRequested(); - return new LoginAttempt(this, expiresUtc, reservedCacheKeys.ToArray(), reservation, observedFailures); + string[] cacheKeys = reservedCacheKeys.ToArray(); + + return new LoginAttempt(expiresUtc, cacheKeys, reservation, observedFailures, () => ReleaseCacheKeysAsync(cacheKeys, reservation)); } - catch (Exception exception) + catch { - try - { - await ReleaseCacheKeysAsync(reservedCacheKeys, reservation); - } - catch (Exception cleanupException) - { - _logger.LogError("Failed to release login admission reservations after {FailureType}: {CleanupFailureType}", - exception.GetType().Name, cleanupException.GetType().Name); - } + await ReleaseCacheKeysAsync(reservedCacheKeys, reservation); throw; } @@ -114,12 +103,19 @@ public async Task ClearUserLoginAttemptsAsync(string emailAddress) { ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress); - var failures = await _cache.GetAllAsync(GetCacheKeys($"user:{emailAddress.Trim().ToLowerInvariant()}", UserFailureLimit, GetWindowExpiration())); + 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. + /// The reserved cache key, or when the admission budget is exhausted. private async Task ReserveCacheKeyAsync(string[] cacheKeys, string reservation, DateTime expiresUtc) { foreach (string cacheKey in cacheKeys) @@ -129,25 +125,76 @@ await RemoveFailuresAsync(failures.Where(pair => pair.Value.HasValue && pair.Val return null; } - private Task ReleaseCacheKeysAsync(IEnumerable cacheKeys, string reservation) - => Task.WhenAll(cacheKeys.Select(cacheKey => _cache.RemoveIfEqualAsync(cacheKey, reservation))); + /// + /// 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) + { + List? cleanupFailures = null; + foreach (string cacheKey in cacheKeys) + { + try + { + await _cache.RemoveIfEqualAsync(cacheKey, reservation); + } + catch (Exception exception) + { + // Cache-provider messages can contain identities, cache keys, or connection details. + var safeException = new Exception("Cache reservation cleanup failed."); + safeException.Data["ExceptionType"] = exception.GetType().FullName; + if (!String.IsNullOrEmpty(exception.StackTrace)) + ExceptionDispatchInfo.SetRemoteStackTrace(safeException, exception.StackTrace); + + (cleanupFailures ??= []).Add(safeException); + } + } + + if (cleanupFailures is null) + return; + + _logger.LogError(new AggregateException("Login admission cleanup failed.", cleanupFailures), + "Failed to release {FailedCacheKeyCount} login admission reservations: {Message}", cleanupFailures.Count, + "Unreleased reservations expire at the current window boundary."); + } private Task RemoveFailuresAsync(IEnumerable> failures) => Task.WhenAll(failures.Select(failure => _cache.RemoveIfEqualAsync(failure.Key, failure.Value))); private DateTime GetWindowExpiration() => _timeProvider.GetUtcNow().UtcDateTime.Floor(AttemptWindow).Add(AttemptWindow); - // The window selects expiration; separate cache entries atomically reserve admission. - private static string[] GetCacheKeys(string cacheKeyPrefix, int limit, DateTime expiresUtc) - => Enumerable.Range(0, limit).Select(index => $"{cacheKeyPrefix}:attempts:{expiresUtc.Ticks}:{index}").ToArray(); + /// + /// 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 and releases unfinished entries when disposed. + /// public sealed class LoginAttempt : IAsyncDisposable { - private readonly AuthService _owner; + private readonly Func _releaseAsync; - internal LoginAttempt(AuthService owner, DateTime expiresUtc, string[] cacheKeys, string reservation, KeyValuePair[] observedFailures) + internal LoginAttempt(DateTime expiresUtc, string[] cacheKeys, string reservation, KeyValuePair[] observedFailures, Func releaseAsync) { - _owner = owner; + _releaseAsync = releaseAsync; ExpiresUtc = expiresUtc; CacheKeys = cacheKeys; Reservation = reservation; @@ -159,6 +206,6 @@ internal LoginAttempt(AuthService owner, DateTime expiresUtc, string[] cacheKeys internal string Reservation { get; } internal KeyValuePair[] ObservedFailures { get; } - public ValueTask DisposeAsync() => new(_owner.ReleaseCacheKeysAsync(CacheKeys, Reservation)); + public ValueTask DisposeAsync() => new(_releaseAsync()); } } diff --git a/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs index 118ffcbe85..a5ecaa4ee3 100644 --- a/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs +++ b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs @@ -76,39 +76,16 @@ protected override async Task HandleAuthenticateAsync() } if (String.Equals(authInfo.Username, "client", StringComparison.OrdinalIgnoreCase)) + { token = authInfo.Password; + } else if (String.Equals(authInfo.Password, "x-oauth-basic", StringComparison.OrdinalIgnoreCase) || String.IsNullOrEmpty(authInfo.Password)) + { token = authInfo.Username; + } else { - string emailAddress = authInfo.Username.Trim().ToLowerInvariant(); - string? ipAddress = Request.GetClientIpAddress(); - await using var loginAttempt = await _authService.TryBeginLoginAsync(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) - { - 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)); + return await AuthenticatePasswordAsync(authInfo); } } else @@ -193,6 +170,38 @@ 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.TryBeginLoginAsync(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) + { + 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 35799bfde6..f22cbc5f3b 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs @@ -300,133 +300,61 @@ public async Task BasicPasswordLogin_MissingStoredCredentials_ReturnsUnauthorize } [Fact] - public async Task CanChangePasswordAsync() + public async Task CancelResetPasswordAsync_ValidToken_ClearsResetToken() { - const string email = "test6@exceptionless.io"; - const string password = "Test6 password"; - const string salt = "1234567890123456"; - string passwordHash = password.ToSaltedHash(salt); - + // Arrange + const string email = "cancel-reset-password@exceptionless.io"; var user = new User { EmailAddress = email, - Password = passwordHash, - Salt = salt, - FullName = "User 6", + FullName = "Cancel Reset Password", Roles = AuthorizationRoles.AllScopes }; user.MarkEmailAddressVerified(); + user.CreatePasswordResetToken(TimeProvider); + string token = user.PasswordResetToken!; await _userRepository.AddAsync(user); - var result = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = email, - Password = password, - }) - .StatusCodeShouldBeOk() - ); - - Assert.NotNull(result); - Assert.NotEmpty(result.Token); - - 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"; - var changePasswordResult = await SendRequestAsAsync(r => r + // Act + using var response = await SendRequestAsync(r => r .Post() - .BasicAuthorization(email, password) - .AppendPath("auth/change-password") - .Content(new ChangePasswordModel - { - CurrentPassword = password, - Password = newPassword - }) + .AppendPath($"auth/cancel-reset-password/{token}") .StatusCodeShouldBeOk() ); - 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 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() - ); + // Assert + Assert.Equal(HttpStatusCode.OK, 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); + var updatedUser = await _userRepository.GetByEmailAddressAsync(email); + Assert.NotNull(updatedUser); + Assert.Null(updatedUser.PasswordResetToken); + Assert.Equal(DateTime.MinValue, updatedUser.PasswordResetTokenExpiration); } [Fact] - public async Task CanLogoutUserAccessTokenAsync() + public async Task CancelResetPasswordAsync_WithNonJsonBody_ReturnsUnsupportedMediaType() { - var token = await _tokenRepository.GetByIdAsync(TestConstants.UserApiKey); - Assert.NotNull(token); - Assert.Equal(TokenType.Access, token.Type); - Assert.False(token.IsDisabled); - Assert.False(token.IsSuspended); + // Arrange + const string token = "test-token"; - await SendRequestAsync(r => r - .BearerToken(token.Id) - .AppendPath("auth/logout") - .StatusCodeShouldBeForbidden() - ); + // Act + using var response = await SendRequestAsync(r => r + .Post() + .AppendPath($"auth/cancel-reset-password/{token}") + .Content("ignored", "text/plain") + .ExpectedStatus(HttpStatusCode.UnsupportedMediaType)); - token = (await _tokenRepository.GetByIdAsync(token.Id))!; - Assert.NotNull(token); - Assert.Equal(TokenType.Access, token.Type); - Assert.False(token.IsDisabled); - Assert.False(token.IsSuspended); + // Assert + Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); } [Fact] - public async Task CanLogoutUserAsync() + public async Task ChangePasswordAsync_ReusedCurrentPassword_ReturnsValidationErrorAndPreservesToken() { - const string email = "test7@exceptionless.io"; - const string password = "Test7 password"; + // Arrange + const string email = "test6@exceptionless.io"; + const string password = "Test6 password"; const string salt = "1234567890123456"; string passwordHash = password.ToSaltedHash(salt); @@ -435,7 +363,7 @@ public async Task CanLogoutUserAsync() EmailAddress = email, Password = passwordHash, Salt = salt, - FullName = "User 7", + FullName = "User 6", Roles = AuthorizationRoles.AllScopes }; @@ -454,27 +382,41 @@ public async Task CanLogoutUserAsync() ); 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); - await SendRequestAsync(r => r - .BearerToken(result.Token) - .AppendPath("auth/logout") - .StatusCodeShouldBeOk() + Assert.NotNull(token.UserId); + var actualUser = await _userRepository.GetByIdAsync(token.UserId); + 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 + { + CurrentPassword = password, + Password = password + }) + .StatusCodeShouldBeUnprocessableEntity() ); - token = await _tokenRepository.GetByIdAsync(result.Token); - Assert.Null(token); + // Assert + Assert.NotNull(problemDetails); + Assert.Single(problemDetails.Errors); + Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); + + Assert.NotNull(await _tokenRepository.GetByIdAsync(result.Token)); } [Fact] - public async Task CanResetPasswordAsync() + public async Task ChangePasswordAsync_ValidPassword_RevokesExistingTokens() { + // Arrange const string email = "test6@exceptionless.io"; const string password = "Test6 password"; const string salt = "1234567890123456"; @@ -490,10 +432,6 @@ public async Task CanResetPasswordAsync() }; 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 @@ -535,459 +473,676 @@ public async Task CanResetPasswordAsync() }, o => o.ImmediateConsistency()); const string newPassword = "NewP@ssword2"; - await SendRequestAsync(r => r + + // Act + var changePasswordResult = await SendRequestAsAsync(r => r .Post() .BasicAuthorization(email, password) - .AppendPath("auth/reset-password") - .Content(new ResetPasswordModel + .AppendPath("auth/change-password") + .Content(new ChangePasswordModel { - PasswordResetToken = user.PasswordResetToken, + CurrentPassword = password, Password = newPassword }) .StatusCodeShouldBeOk() ); + // Assert + 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 CanSignupWhenAccountCreationDisabledWithValidTokenAndInvalidAdAccountAsync() + public async Task CheckEmailAddressAsync_ExistingUser_ReturnsCreated() { - _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 + // Arrange + const string email = "existing-email-check@exceptionless.io"; + var user = new User { - Token = StringExtensions.GetNewToken(), - EmailAddress = email.ToLowerInvariant(), - DateAdded = DateTime.UtcNow + EmailAddress = email, + FullName = "Existing Email Check", + Roles = AuthorizationRoles.AllScopes }; - 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 - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = email, - Password = password, - InviteToken = invite.Token - }) - .StatusCodeShouldBeUnauthorized() + // Act + using var response = await SendRequestAsync(r => r + .AppendPath($"auth/check-email-address/{email}") + .StatusCodeShouldBeCreated() ); + + // Assert + Assert.Equal(HttpStatusCode.Created, 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) + [Fact] + public async Task CheckEmailAddressAsync_MissingUser_ReturnsNoContent() { - _authOptions.EnableAccountCreation = false; - _authOptions.EnableActiveDirectoryAuth = enableAdAuth; + // Arrange + const string email = "missing-email-check@exceptionless.io"; - if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) - { - var provider = new TestDomainLoginProvider(); - email = provider.GetEmailAddressFromUsername(email); - } + // Act + using var response = await SendRequestAsync(r => r + .AppendPath($"auth/check-email-address/{email}") + .StatusCodeShouldBeNoContent() + ); - var results = await _organizationRepository.GetAllAsync(); - var organization = results.Documents.First(); + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } - 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)); + [Fact] + public async Task FacebookAsync_WithConfiguredProvider_ReturnsToken() + { + // Arrange + const string code = "facebook-user"; - var result = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = email, - Password = password, - InviteToken = invite.Token - }) - .StatusCodeShouldBeOk() - ); + // Act + var result = await SendExternalLoginAsync("facebook", code); + + // Assert + 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] + public async Task GitHubAsync_WithoutInviteAndAuthenticatedSession_LinksCurrentUser() + { + // Arrange + const string code = "github-linked-user"; + var currentUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(currentUser); + + // Act + var result = await SendRequestAsAsync(request => request + .Post() + .AsTestOrganizationUser() + .AppendPaths("auth", "github") + .Content(new ExternalAuthInfo + { + ClientId = "client-id", + Code = code, + RedirectUri = "http://localhost/callback" + }) + .StatusCodeShouldBeOk() + ); + + // Assert Assert.NotNull(result); - Assert.False(String.IsNullOrEmpty(result.Token)); + var token = await _tokenRepository.GetByIdAsync(result.Token); + Assert.NotNull(token); + Assert.Equal(currentUser.Id, token.UserId); + + currentUser = await _userRepository.GetByIdAsync(currentUser.Id); + Assert.NotNull(currentUser); + Assert.Contains(currentUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + } + + [Fact] + public async Task GitHubAsync_WithValidInviteAndAccountCreationDisabled_CreatesInvitedUser() + { + // Arrange + _authOptions.EnableAccountCreation = false; + const string code = "github-invited-user"; + string email = TestOAuthProviderClient.GetEmailAddress(code); + var organization = (await _organizationRepository.GetAllAsync()).Documents.First(); + var invite = new Invite + { + Token = StringExtensions.GetNewToken(), + EmailAddress = email, + DateAdded = DateTime.UtcNow + }; + organization.Invites.Add(invite); + await _organizationRepository.SaveAsync(organization, options => options.ImmediateConsistency()); + // Act + var result = await SendExternalLoginAsync("github", code, invite.Token); + + // Assert + await AssertExternalLoginAsync(result, "github", code); 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.Contains(organization.Id, user.OrganizationIds); + var updatedOrganization = await _organizationRepository.GetByIdAsync(organization.Id); + Assert.NotNull(updatedOrganization); + Assert.DoesNotContain(updatedOrganization.Invites, candidate => candidate.Token == invite.Token); + } - // Assert user is verified due to the invite. - Assert.True(user.IsEmailAddressVerified); - Assert.Null(user.VerifyEmailAddressToken); - Assert.Equal(DateTime.MinValue, user.VerifyEmailAddressTokenExpiration); + [Fact] + public async Task GitHubAsync_WithValidInviteAndAuthenticatedSession_AuthenticatesInvitedUser() + { + // Arrange + const string code = "github-existing-invited-user"; + string invitedEmail = TestOAuthProviderClient.GetEmailAddress(code); + var initialLogin = await SendExternalLoginAsync("github", code); + Assert.NotNull(initialLogin); + + var invitedUser = await _userRepository.GetByEmailAddressAsync(invitedEmail); + Assert.NotNull(invitedUser); + var currentUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(currentUser); + string[] currentOrganizationIds = currentUser.OrganizationIds.ToArray(); + + var organization = (await _organizationRepository.GetAllAsync()).Documents.First(); + var invite = new Invite + { + Token = StringExtensions.GetNewToken(), + EmailAddress = invitedEmail, + DateAdded = DateTime.UtcNow + }; + organization.Invites.Add(invite); + await _organizationRepository.SaveAsync(organization, options => options.ImmediateConsistency()); + + // Act + var result = await SendRequestAsAsync(request => request + .Post() + .AsTestOrganizationUser() + .AppendPaths("auth", "github") + .Content(new ExternalAuthInfo + { + ClientId = "client-id", + Code = code, + InviteToken = invite.Token, + RedirectUri = "http://localhost/callback" + }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(result); + var token = await _tokenRepository.GetByIdAsync(result.Token); + Assert.NotNull(token); + Assert.Equal(invitedUser.Id, token.UserId); + + invitedUser = await _userRepository.GetByIdAsync(invitedUser.Id); + Assert.NotNull(invitedUser); + Assert.Contains(invitedUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + Assert.Contains(organization.Id, invitedUser.OrganizationIds); + + currentUser = await _userRepository.GetByIdAsync(currentUser.Id); + Assert.NotNull(currentUser); + Assert.DoesNotContain(currentUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + Assert.Equal(currentOrganizationIds, currentUser.OrganizationIds); + + var updatedOrganization = await _organizationRepository.GetByIdAsync(organization.Id); + Assert.NotNull(updatedOrganization); + Assert.DoesNotContain(updatedOrganization.Invites, candidate => candidate.Token == invite.Token); + } + + [Fact] + public async Task GoogleAsync_WithConfiguredProvider_ReturnsToken() + { + // Arrange + const string code = "google-user"; + + // Act + var result = await SendExternalLoginAsync("google", code); + + // Assert + await AssertExternalLoginAsync(result, "google", code); } [Fact] - public Task CanSignupWhenAccountCreationEnabledWithNoTokenAndInvalidAdAccountAsync() + public async Task LiveAsync_WithConfiguredProvider_ReturnsToken() { - _authOptions.EnableAccountCreation = true; - _authOptions.EnableActiveDirectoryAuth = true; + // Arrange + const string code = "live-user"; - return SendRequestAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = "testuser2@exceptionless.io", - Password = "literallydoesntmatter", - InviteToken = null - }) - .StatusCodeShouldBeUnauthorized() - ); + // Act + var result = await SendExternalLoginAsync("live", code); + + // Assert + await AssertExternalLoginAsync(result, "windowslive", code); } [Fact] - public async Task CanSignupWhenAccountCreationEnabledWithNoTokenAndValidAdAccountAsync() + public async Task LoginAsync_ExistingActiveDirectoryAccountWithValidPassword_ReturnsToken() { - _authOptions.EnableAccountCreation = true; + // Arrange _authOptions.EnableActiveDirectoryAuth = true; var provider = new TestDomainLoginProvider(); string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); + var user = new User + { + EmailAddress = email, + FullName = "User 6" + }; - var result = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = email, - Password = TestDomainLoginProvider.ValidPassword, - InviteToken = null - }) - .StatusCodeShouldBeOk() - ); - - Assert.NotNull(result); - Assert.False(String.IsNullOrEmpty(result.Token)); - } - - [Fact] - public async Task CanSignupWhenAccountCreationEnabledWithNoTokenAsync() - { - _authOptions.EnableAccountCreation = true; - - const string email = "test4@exceptionless.io"; - const string password = "Password1$"; + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); + // Act var result = await SendRequestAsAsync(r => r .Post() - .AppendPath("auth/signup") - .Content(new Signup + .AppendPath("auth/login") + .Content(new Login { - Name = "Test", Email = email, - Password = password, - InviteToken = null + Password = TestDomainLoginProvider.ValidPassword }) .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 CanSignupWhenAccountCreationEnabledWithValidTokenAndInvalidAdAccountAsync() + public async Task LoginAsync_ExistingActiveDirectoryEmailWithInvalidPassword_ReturnsUnauthorized() { - _authOptions.EnableAccountCreation = true; + // Arrange _authOptions.EnableActiveDirectoryAuth = true; - string email = "test-user4@exceptionless.io"; - var results = await _organizationRepository.GetAllAsync(); - var organization = results.Documents.First(); - var invite = new Invite + var provider = new TestDomainLoginProvider(); + string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); + var user = new User { - Token = StringExtensions.GetNewToken(), - EmailAddress = email.ToLowerInvariant(), - DateAdded = DateTime.UtcNow + EmailAddress = email, + FullName = "User 6" }; - organization.Invites.Add(invite); - await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); - Assert.NotNull(organization.GetInvite(invite.Token)); - await SendRequestAsync(r => r + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); + + // Act + using var response = await SendRequestAsync(r => r .Post() - .AppendPath("auth/signup") - .Content(new Signup + .AppendPath("auth/login") + .Content(new Login { - Name = "Test", Email = email, - Password = TestDomainLoginProvider.ValidPassword, - InviteToken = invite.Token + Password = "Totallywrongpassword1234" }) .StatusCodeShouldBeUnauthorized() ); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task CanSignupWhenAccountCreationEnabledWithValidTokenAndValidAdAccountAsync() + public async Task LoginAsync_ExistingActiveDirectoryUsernameWithInvalidPassword_ReturnsUnauthorized() { - _authOptions.EnableAccountCreation = true; + // Arrange _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 + var user = new User { - Token = StringExtensions.GetNewToken(), - EmailAddress = email.ToLowerInvariant(), - DateAdded = DateTime.UtcNow + EmailAddress = email, + FullName = "User 6" }; - organization.Invites.Add(invite); - 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 = TestDomainLoginProvider.ValidPassword, - InviteToken = invite.Token - }) - .StatusCodeShouldBeOk() + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); + + // Act + using var response = await SendRequestAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = TestDomainLoginProvider.ValidUsername, + Password = "Totallywrongpassword1234" + }) + .StatusCodeShouldBeUnauthorized() ); - Assert.NotNull(result); - Assert.False(String.IsNullOrEmpty(result.Token)); + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task CanSignupWhenAccountCreationEnabledWithValidTokenAsync() + public async Task LoginAsync_InvalidPassword_ReturnsUnauthorized() { - _authOptions.EnableAccountCreation = true; + // Arrange + _authOptions.EnableActiveDirectoryAuth = false; - var organizations = await _organizationRepository.GetAllAsync(); - var organization = organizations.Documents.First(); - const string email = "test5@exceptionless.io"; - const string name = "Test"; - const string password = "Password1$"; + const string email = "test7@exceptionless.io"; + const string password = "Test7 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 7" }; - 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 + // Act + using var response = await SendRequestAsync(r => r .Post() - .AppendPath("auth/signup") - .Content(new Signup + .AppendPath("auth/login") + .Content(new Login { - Name = name, Email = email, - Password = password, - InviteToken = invite.Token + Password = "This password ain't right" }) - .StatusCodeShouldBeOk() + .StatusCodeShouldBeUnauthorized() ); - Assert.NotNull(result); - Assert.False(String.IsNullOrEmpty(result.Token)); - - await RefreshDataAsync(); + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } - 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); + [Fact] + public async Task LoginAsync_MissingLocalUserWithValidActiveDirectoryCredentials_ReturnsUnauthorized() + { + // Arrange + _authOptions.EnableActiveDirectoryAuth = true; - organization = await _organizationRepository.GetByIdAsync(organization.Id); - Assert.NotNull(organization); - Assert.Empty(organization.Invites); + var provider = new TestDomainLoginProvider(); + string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); - var token = await _tokenRepository.GetByIdAsync(result.Token); - Assert.NotNull(token); - Assert.Equal(user.Id, token.UserId); - Assert.Equal(TokenType.Authentication, token.Type); + // Act + using var response = await SendRequestAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = TestDomainLoginProvider.ValidPassword + }) + .StatusCodeShouldBeUnauthorized() + ); - var mailQueue = GetService>() as InMemoryQueue; - Assert.NotNull(mailQueue); - Assert.Equal(0, (await mailQueue.GetQueueStatsAsync()).Enqueued); + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task CancelResetPasswordAsync_WithNonJsonBody_ReturnsUnsupportedMediaType() + public async Task LoginAsync_NonexistentActiveDirectoryAccount_ReturnsUnauthorizedWithoutCreatingUser() { // Arrange - const string token = "test-token"; + _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)); + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = $"{email}.au", + Password = "Totallywrongpassword1234" + }) + .StatusCodeShouldBeUnauthorized() + ); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - // Assert - Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + // Verify that a user account was not added + var user = await _userRepository.GetByEmailAddressAsync($"{email}.au"); + Assert.Null(user); } [Fact] - public async Task CancelResetPasswordClearsTokenAsync() + public async Task LoginAsync_UnknownEmail_ReturnsUnauthorized() { - const string email = "cancel-reset-password@exceptionless.io"; + // Arrange + _authOptions.EnableActiveDirectoryAuth = false; + + 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 = "Cancel Reset Password", - Roles = AuthorizationRoles.AllScopes + Password = passwordHash, + Salt = salt, + FullName = "User 8" }; 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/login") + .Content(new Login + { + Email = "Thisguydoesntexist@exceptionless.io", + Password = "This password ain't right" + }) + .StatusCodeShouldBeUnauthorized() ); - var updatedUser = await _userRepository.GetByEmailAddressAsync(email); - Assert.NotNull(updatedUser); - Assert.Null(updatedUser.PasswordResetToken); - Assert.Equal(DateTime.MinValue, updatedUser.PasswordResetTokenExpiration); - } - - [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) - { - _authOptions.EnableAccountCreation = false; - _authOptions.EnableActiveDirectoryAuth = enableAdAuth; - - if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) - { - var provider = new TestDomainLoginProvider(); - email = provider.GetEmailAddressFromUsername(email); - } - - 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.Unauthorized, 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) + [Fact] + public async Task LoginAsync_ValidPassword_ReturnsToken() { - _authOptions.EnableAccountCreation = false; - _authOptions.EnableActiveDirectoryAuth = enableAdAuth; + // Arrange + _authOptions.EnableActiveDirectoryAuth = false; - if (enableAdAuth && email == 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 { - var provider = new TestDomainLoginProvider(); - email = provider.GetEmailAddressFromUsername(email); - } + EmailAddress = email, + Password = passwordHash, + Salt = salt, + FullName = "User 6" + }; - return SendRequestAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "Test", - Email = email, - Password = password, - InviteToken = null - }) - .StatusCodeShouldBeForbidden() - ); - } + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user); - [Fact] - public async Task CannotSignupWithoutPassword() - { - var problemDetails = await SendRequestAsAsync(r => r - .Post() - .AppendPath("auth/signup") - .Content(new Signup - { - Name = "hello", - Email = "test@domain.com", - Password = null! - }) - .StatusCodeShouldBeUnprocessableEntity() + // Act + var result = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = password + }) + .StatusCodeShouldBeOk() ); - Assert.NotNull(problemDetails); - Assert.Single(problemDetails.Errors); - Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); + // Assert + Assert.NotNull(result); + Assert.False(String.IsNullOrEmpty(result.Token)); } [Fact] - public async Task ChangePasswordShouldFailWithCurrentPasswordAsync() + 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); @@ -996,7 +1151,7 @@ public async Task ChangePasswordShouldFailWithCurrentPasswordAsync() EmailAddress = email, Password = passwordHash, Salt = salt, - FullName = "User 6", + FullName = "User 7", Roles = AuthorizationRoles.AllScopes }; @@ -1015,866 +1170,834 @@ public async Task ChangePasswordShouldFailWithCurrentPasswordAsync() ); 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 problemDetails = await SendRequestAsAsync(r => r - .Post() - .BasicAuthorization(email, password) - .AppendPath("auth/change-password") - .Content(new ChangePasswordModel - { - CurrentPassword = password, - Password = password - }) - .StatusCodeShouldBeUnprocessableEntity() + // Act + using var response = await SendRequestAsync(r => r + .BearerToken(result.Token) + .AppendPath("auth/logout") + .StatusCodeShouldBeOk() ); - Assert.NotNull(problemDetails); - Assert.Single(problemDetails.Errors); - Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(await _tokenRepository.GetByIdAsync(result.Token)); + token = await _tokenRepository.GetByIdAsync(result.Token); + Assert.Null(token); } [Fact] - public async Task EmailAddressAvailabilityReturnsCreatedForExistingUserAsync() + public async Task LogoutAsync_ClientAccessToken_ReturnsForbiddenAndPreservesToken() { - 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); + // 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); - await SendRequestAsync(r => r - .AppendPath($"auth/check-email-address/{email}") - .StatusCodeShouldBeCreated() + // 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 Task EmailAddressAvailabilityReturnsNoContentForMissingUserAsync() + public async Task LogoutAsync_UserAccessToken_ReturnsForbiddenAndPreservesToken() { - return SendRequestAsync(r => r - .AppendPath("auth/check-email-address/missing-email-check@exceptionless.io") - .StatusCodeShouldBeNoContent() + // 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 FacebookAsync_WithConfiguredProvider_ReturnsToken() + public async Task PasswordLogin_FailuresThroughBasic_AreThrottled() { // Arrange - const string code = "facebook-user"; + 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 - var result = await SendExternalLoginAsync("facebook", code); + using var response = await client.PostAsJsonAsync("api/v2/auth/login", + new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }, + TestCancellationToken); // Assert - await AssertExternalLoginAsync(result, "facebook", code); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(originalTokenCount, (await _tokenRepository.CountAsync()).Total); } [Fact] - public async Task ForgotPasswordCreatesResetTokenAsync() + public async Task PasswordLogin_ThrottlingExpires_LogsInWithoutPasswordResetOrReactivation() { - const string email = "forgot-password@exceptionless.io"; - var user = new User + // 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++) { - EmailAddress = email, - FullName = "Forgot Password", - Roles = AuthorizationRoles.AllScopes - }; - - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user); - - await SendRequestAsync(r => r - .AppendPath($"auth/forgot-password/{email}") - .StatusCodeShouldBeOk() - ); + 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 }; - var updatedUser = await _userRepository.GetByEmailAddressAsync(email); - Assert.NotNull(updatedUser); - Assert.False(String.IsNullOrEmpty(updatedUser.PasswordResetToken)); - Assert.True(updatedUser.PasswordResetTokenExpiration.IsAfter(TimeProvider.GetUtcNow().UtcDateTime)); - } + // 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); - [Fact] - public Task ForgotPasswordForUnknownEmailReturnsOkAsync() - { - return SendRequestAsync(r => r - .AppendPath("auth/forgot-password/missing-password-user@exceptionless.io") - .StatusCodeShouldBeOk() - ); + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, blocked.StatusCode); + Assert.NotNull(throttledUser); + Assert.True(throttledUser.IsActive); + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); } [Fact] - public async Task GetIntercomToken_WhenIntercomIsDisabled_ReturnsUnprocessableEntityAsync() + public async Task RemoveExternalLoginAsync_WithLinkedAccount_RemovesAccount() { // Arrange - _intercomOptions.IntercomSecret = null; + 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 problemDetails = await SendRequestAsAsync(r => r - .BearerToken(TestConstants.UserApiKey) - .AppendPath("auth/intercom") - .StatusCodeShouldBeUnprocessableEntity() + var result = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("auth", "unlink", providerName) + .Content(new ValueFromBody(providerUserId)) + .StatusCodeShouldBeOk() ); // Assert - Assert.NotNull(problemDetails); - Assert.True(problemDetails.Errors.TryGetValue("intercom", out string[]? intercomErrors)); - Assert.Contains("Intercom is not enabled.", intercomErrors); + 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 GetIntercomToken_WhenUnauthenticated_ReturnsUnauthorizedAsync() + public async Task RemoveExternalLoginAsync_WithoutProviderUserId_ReturnsBadRequest() { // Arrange - _intercomOptions.IntercomSecret = "test-intercom-secret-with-adequate-length-12345"; + var providerUserId = new ValueFromBody(String.Empty); // Act - return SendRequestAsync(r => r - .AppendPath("auth/intercom") - .StatusCodeShouldBeUnauthorized() + 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 GetIntercomToken_WithValidAuthenticatedUser_ReturnsJwtAsync() + public async Task ResetPasswordAsync_ReusedCurrentPassword_ReturnsValidationErrorAndPreservesToken() { // 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", + Roles = AuthorizationRoles.AllScopes }; user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user, o => o.ImmediateConsistency()); + user.CreatePasswordResetToken(TimeProvider); + Assert.NotNull(user.PasswordResetToken); + Assert.True(user.PasswordResetTokenExpiration.IsAfter(TimeProvider.GetUtcNow().UtcDateTime)); - var authToken = await SendRequestAsAsync(r => r + await _userRepository.AddAsync(user); + + var result = await SendRequestAsAsync(r => r .Post() .AppendPath("auth/login") .Content(new Login { Email = email, - Password = password + 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.NotNull(result); + Assert.NotEmpty(result.Token); - // Assert - await AssertExternalLoginAsync(result, "github", code); - } + var token = await _tokenRepository.GetByIdAsync(result.Token); + Assert.NotNull(token); - [Fact] - public async Task GitHubAsync_WithInvalidInviteAndAccountCreationDisabled_IsForbidden() - { - // Arrange - _authOptions.EnableAccountCreation = false; - const string code = "github-invited-user"; - string email = TestOAuthProviderClient.GetEmailAddress(code); + Assert.NotNull(token.UserId); + var actualUser = await _userRepository.GetByIdAsync(token.UserId); + Assert.NotNull(actualUser); + Assert.Equal(email, actualUser.EmailAddress); // Act - await SendRequestAsync(r => r + var problemDetails = await SendRequestAsAsync(r => r .Post() - .AppendPaths("auth", "github") - .Content(new ExternalAuthInfo + .BasicAuthorization(email, password) + .AppendPath("auth/reset-password") + .Content(new ResetPasswordModel { - ClientId = "client-id", - Code = code, - InviteToken = StringExtensions.GetNewToken(), - RedirectUri = "http://localhost/callback" + PasswordResetToken = user.PasswordResetToken, + Password = password }) - .StatusCodeShouldBeForbidden() + .StatusCodeShouldBeUnprocessableEntity() ); // Assert - Assert.Null(await _userRepository.GetByEmailAddressAsync(email)); + Assert.NotNull(problemDetails); + Assert.Single(problemDetails.Errors); + Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); + + Assert.NotNull(await _tokenRepository.GetByIdAsync(result.Token)); } [Fact] - public async Task GitHubAsync_WithValidInviteAndAccountCreationDisabled_CreatesInvitedUser() + public async Task ResetPasswordAsync_ValidResetToken_RevokesExistingTokens() { // Arrange - _authOptions.EnableAccountCreation = false; - const string code = "github-invited-user"; - string email = TestOAuthProviderClient.GetEmailAddress(code); - var organization = (await _organizationRepository.GetAllAsync()).Documents.First(); - var invite = new Invite + const string email = "test6@exceptionless.io"; + const string password = "Test6 password"; + const string salt = "1234567890123456"; + string passwordHash = password.ToSaltedHash(salt); + + var user = new User { - Token = StringExtensions.GetNewToken(), EmailAddress = email, - DateAdded = DateTime.UtcNow + Password = passwordHash, + Salt = salt, + FullName = "User 6", + Roles = AuthorizationRoles.AllScopes }; - organization.Invites.Add(invite); - await _organizationRepository.SaveAsync(organization, options => options.ImmediateConsistency()); - - // Act - var result = await SendExternalLoginAsync("github", code, invite.Token); - - // Assert - await AssertExternalLoginAsync(result, "github", code); - var user = await _userRepository.GetByEmailAddressAsync(email); - Assert.NotNull(user); - Assert.Contains(organization.Id, user.OrganizationIds); - var updatedOrganization = await _organizationRepository.GetByIdAsync(organization.Id); - Assert.NotNull(updatedOrganization); - Assert.DoesNotContain(updatedOrganization.Invites, candidate => candidate.Token == invite.Token); - } - - [Fact] - public async Task GitHubAsync_WithValidInviteAndAuthenticatedSession_AuthenticatesInvitedUser() - { - // Arrange - const string code = "github-existing-invited-user"; - string invitedEmail = TestOAuthProviderClient.GetEmailAddress(code); - var initialLogin = await SendExternalLoginAsync("github", code); - Assert.NotNull(initialLogin); - var invitedUser = await _userRepository.GetByEmailAddressAsync(invitedEmail); - Assert.NotNull(invitedUser); - var currentUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); - Assert.NotNull(currentUser); - string[] currentOrganizationIds = currentUser.OrganizationIds.ToArray(); + user.MarkEmailAddressVerified(); + user.CreatePasswordResetToken(TimeProvider); + Assert.NotNull(user.PasswordResetToken); + Assert.True(user.PasswordResetTokenExpiration.IsAfter(TimeProvider.GetUtcNow().UtcDateTime)); - var organization = (await _organizationRepository.GetAllAsync()).Documents.First(); - var invite = new Invite - { - Token = StringExtensions.GetNewToken(), - EmailAddress = invitedEmail, - DateAdded = DateTime.UtcNow - }; - organization.Invites.Add(invite); - await _organizationRepository.SaveAsync(organization, options => options.ImmediateConsistency()); + await _userRepository.AddAsync(user); - // Act - var result = await SendRequestAsAsync(request => request + var result = await SendRequestAsAsync(r => r .Post() - .AsTestOrganizationUser() - .AppendPaths("auth", "github") - .Content(new ExternalAuthInfo - { - ClientId = "client-id", - Code = code, - InviteToken = invite.Token, - RedirectUri = "http://localhost/callback" + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = password, }) .StatusCodeShouldBeOk() ); - // Assert Assert.NotNull(result); + Assert.NotEmpty(result.Token); + var token = await _tokenRepository.GetByIdAsync(result.Token); Assert.NotNull(token); - Assert.Equal(invitedUser.Id, token.UserId); - - invitedUser = await _userRepository.GetByIdAsync(invitedUser.Id); - Assert.NotNull(invitedUser); - Assert.Contains(invitedUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); - Assert.Contains(organization.Id, invitedUser.OrganizationIds); - - currentUser = await _userRepository.GetByIdAsync(currentUser.Id); - Assert.NotNull(currentUser); - Assert.DoesNotContain(currentUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); - Assert.Equal(currentOrganizationIds, currentUser.OrganizationIds); - var updatedOrganization = await _organizationRepository.GetByIdAsync(organization.Id); - Assert.NotNull(updatedOrganization); - Assert.DoesNotContain(updatedOrganization.Invites, candidate => candidate.Token == invite.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()); - [Fact] - public async Task GitHubAsync_WithoutInviteAndAuthenticatedSession_LinksCurrentUser() - { - // Arrange - const string code = "github-linked-user"; - var currentUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); - Assert.NotNull(currentUser); + const string newPassword = "NewP@ssword2"; // Act - var result = await SendRequestAsAsync(request => request + using var response = await SendRequestAsync(r => r .Post() - .AsTestOrganizationUser() - .AppendPaths("auth", "github") - .Content(new ExternalAuthInfo + .BasicAuthorization(email, password) + .AppendPath("auth/reset-password") + .Content(new ResetPasswordModel { - ClientId = "client-id", - Code = code, - RedirectUri = "http://localhost/callback" + PasswordResetToken = user.PasswordResetToken, + Password = newPassword }) .StatusCodeShouldBeOk() ); // Assert - Assert.NotNull(result); - var token = await _tokenRepository.GetByIdAsync(result.Token); - Assert.NotNull(token); - Assert.Equal(currentUser.Id, token.UserId); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); - currentUser = await _userRepository.GetByIdAsync(currentUser.Id); - Assert.NotNull(currentUser); - Assert.Contains(currentUser.OAuthAccounts, account => account.Provider == "github" && account.ProviderUserId == code); + Assert.Null(await _tokenRepository.GetByIdAsync(result.Token)); + Assert.Null(await _oauthTokenRepository.GetByIdAsync(oauthToken.Id, o => o.ImmediateConsistency())); } [Fact] - public async Task GoogleAsync_WithConfiguredProvider_ReturnsToken() + public async Task ResetPassword_MissingRemoteIpAddress_ClearsUserLoginAttempts() { // Arrange - const string code = "google-user"; + 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++) + { + 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(); // Act - var result = await SendExternalLoginAsync("google", code); + 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 - await AssertExternalLoginAsync(result, "google", code); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(loginAttemptAfterReset); } - [Fact] - public async Task LiveAsync_WithConfiguredProvider_ReturnsToken() + [Theory] + [InlineData(false, HttpStatusCode.Unauthorized)] + [InlineData(true, HttpStatusCode.OK)] + public async Task ResetPassword_PreservesActiveState_OnlyActiveUsersCanLogIn(bool isActive, HttpStatusCode expectedStatus) { // Arrange - const string code = "live-user"; + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(user); + user = user with { IsActive = isActive }; + user.CreatePasswordResetToken(TimeProvider); + Assert.NotNull(user.PasswordResetToken); + await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); + using var client = _server.CreateClient(); + const string newPassword = "Password2$"; // Act - var result = await SendExternalLoginAsync("live", code); + using var reset = await client.PostAsJsonAsync("api/v2/auth/reset-password", + new ResetPasswordModel { PasswordResetToken = user.PasswordResetToken, Password = newPassword }, + GetService(), TestCancellationToken); + var storedUser = await _userRepository.GetByIdAsync(user.Id, options => options.ImmediateConsistency()); + using var login = await client.PostAsJsonAsync("api/v2/auth/login", + new Login { Email = user.EmailAddress, Password = newPassword }, TestCancellationToken); + using var basicRequest = new HttpRequestMessage(HttpMethod.Get, "api/v2/users/me"); + basicRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user.EmailAddress}:{newPassword}"))); + using var basicLogin = await client.SendAsync(basicRequest, TestCancellationToken); // Assert - await AssertExternalLoginAsync(result, "windowslive", code); + Assert.Equal(HttpStatusCode.OK, reset.StatusCode); + Assert.NotNull(storedUser); + Assert.Equal(isActive, storedUser.IsActive); + Assert.True(storedUser.IsCorrectPassword(newPassword)); + Assert.Equal(expectedStatus, login.StatusCode); + Assert.Equal(expectedStatus, basicLogin.StatusCode); } - [Fact] - public async Task LoginInvalidExistingActiveDirectoryAccountUsingUserNameLoginAsync() + [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) { - _authOptions.EnableActiveDirectoryAuth = true; + // Arrange + _authOptions.EnableAccountCreation = false; + _authOptions.EnableActiveDirectoryAuth = enableAdAuth; - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); - var user = new User + if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) { - EmailAddress = email, - FullName = "User 6" - }; - - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user); + var provider = new TestDomainLoginProvider(); + email = provider.GetEmailAddressFromUsername(email); + } - await SendRequestAsync(r => r + // Act + using var response = await SendRequestAsync(r => r .Post() - .AppendPath("auth/login") - .Content(new Login + .AppendPath("auth/signup") + .Content(new Signup { - Email = TestDomainLoginProvider.ValidUsername, - Password = "Totallywrongpassword1234" + Name = "Test", + Email = email, + Password = password, + InviteToken = StringExtensions.GetNewToken() }) - .StatusCodeShouldBeUnauthorized() - ); - } - - [Fact] - public async Task LoginInvalidExistingActiveDirectoryAsync() - { - _authOptions.EnableActiveDirectoryAuth = true; - - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); - var user = new User - { - EmailAddress = email, - FullName = "User 6" - }; - - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user); - - await SendRequestAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = email, - Password = "Totallywrongpassword1234" - }) - .StatusCodeShouldBeUnauthorized() - ); - } - - [Fact] - public async Task LoginInvalidNonExistentActiveDirectoryAsync() - { - _authOptions.EnableActiveDirectoryAuth = true; - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); - - await SendRequestAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = $"{email}.au", - Password = "Totallywrongpassword1234" - }) - .StatusCodeShouldBeUnauthorized() + .StatusCodeShouldBeForbidden() ); - // Verify that a user account was not added - var user = await _userRepository.GetByEmailAddressAsync($"{email}.au"); - Assert.Null(user); + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } - [Fact] - public async Task LoginInvalidPasswordAsync() + [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) { - _authOptions.EnableActiveDirectoryAuth = false; - - const string email = "test7@exceptionless.io"; - const string password = "Test7 password"; - const string salt = "1234567890123456"; - string passwordHash = password.ToSaltedHash(salt); + // Arrange + _authOptions.EnableAccountCreation = false; + _authOptions.EnableActiveDirectoryAuth = enableAdAuth; - var user = new User + if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) { - EmailAddress = email, - Password = passwordHash, - Salt = salt, - FullName = "User 7" - }; - - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user); + var provider = new TestDomainLoginProvider(); + email = provider.GetEmailAddressFromUsername(email); + } - await SendRequestAsync(r => r - .Post() - .AppendPath("auth/login") - .Content(new Login - { - Email = email, - Password = "This password ain't right" - }) - .StatusCodeShouldBeUnauthorized() + // Act + using var response = await SendRequestAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Test", + Email = email, + Password = password, + InviteToken = null + }) + .StatusCodeShouldBeForbidden() ); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] - public async Task LoginNoSuchUserAsync() + public async Task SignupAsync_AccountCreationDisabledWithValidInviteAndInvalidActiveDirectoryAccount_ReturnsUnauthorized() { - _authOptions.EnableActiveDirectoryAuth = false; + // Arrange + _authOptions.EnableAccountCreation = false; + _authOptions.EnableActiveDirectoryAuth = true; - const string email = "test8@exceptionless.io"; - const string password = "Test8 password"; - const string salt = "1234567890123456"; - string passwordHash = password.ToSaltedHash(salt); - var user = new User + 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, - Password = passwordHash, - Salt = salt, - FullName = "User 8" + 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 + // Act + using var response = await SendRequestAsync(r => r .Post() - .AppendPath("auth/login") - .Content(new Login + .AppendPath("auth/signup") + .Content(new Signup { - Email = "Thisguydoesntexist@exceptionless.io", - Password = "This password ain't right" + Name = "Test", + Email = email, + Password = password, + InviteToken = invite.Token }) .StatusCodeShouldBeUnauthorized() ); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } - [Fact] - public async Task LoginValidAsync() + [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) { - _authOptions.EnableActiveDirectoryAuth = false; + // Arrange + _authOptions.EnableAccountCreation = false; + _authOptions.EnableActiveDirectoryAuth = enableAdAuth; - const string email = "test6@exceptionless.io"; - const string password = "Test6 password"; - const string salt = "1234567890123456"; - string passwordHash = password.ToSaltedHash(salt); - var user = new User + if (enableAdAuth && email == TestDomainLoginProvider.ValidUsername) { - EmailAddress = email, - Password = passwordHash, - Salt = salt, - FullName = "User 6" - }; + var provider = new TestDomainLoginProvider(); + email = provider.GetEmailAddressFromUsername(email); + } - user.MarkEmailAddressVerified(); - await _userRepository.AddAsync(user); + 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/login") - .Content(new Login + .AppendPath("auth/signup") + .Content(new Signup { + Name = "Test", Email = email, - Password = password + 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 async Task LoginValidExistingActiveDirectoryAsync() + public async Task SignupAsync_AccountCreationEnabledWithoutInviteAndInvalidActiveDirectoryAccount_ReturnsUnauthorized() { + // Arrange + _authOptions.EnableAccountCreation = true; _authOptions.EnableActiveDirectoryAuth = true; - var provider = new TestDomainLoginProvider(); - string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); - var user = new User - { - EmailAddress = email, - FullName = "User 6" - }; - - 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 + .AppendPath("auth/signup") + .Content(new Signup { - Email = email, - Password = TestDomainLoginProvider.ValidPassword + Name = "Test", + Email = "testuser2@exceptionless.io", + Password = "literallydoesntmatter", + InviteToken = null }) - .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 SignupAsync_AccountCreationEnabledWithoutInviteAndValidActiveDirectoryAccount_ReturnsToken() { + // Arrange + _authOptions.EnableAccountCreation = true; _authOptions.EnableActiveDirectoryAuth = true; var provider = new TestDomainLoginProvider(); string email = provider.GetEmailAddressFromUsername(TestDomainLoginProvider.ValidUsername); - return SendRequestAsync(r => r + // Act + var result = await SendRequestAsAsync(r => r .Post() - .AppendPath("auth/login") - .Content(new Login + .AppendPath("auth/signup") + .Content(new Signup { + Name = "Test", Email = email, - Password = TestDomainLoginProvider.ValidPassword + Password = TestDomainLoginProvider.ValidPassword, + InviteToken = null }) - .StatusCodeShouldBeUnauthorized() + .StatusCodeShouldBeOk() ); - } - - [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); + Assert.NotNull(result); + Assert.False(String.IsNullOrEmpty(result.Token)); } [Fact] - public async Task PasswordLogin_MissingRemoteIpAddress_StillEnforcesUserLimit() + public async Task SignupAsync_AccountCreationEnabledWithoutInvite_CreatesUnverifiedUser() { // 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); - } + _authOptions.EnableAccountCreation = true; + + const string email = "test4@exceptionless.io"; + const string password = "Password1$"; // Act - using var response = await client.PostAsJsonAsync("api/v2/auth/login", - new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD }, - 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.Unauthorized, response.StatusCode); - Assert.Equal(originalTokenCount, (await _tokenRepository.CountAsync()).Total); + 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 PasswordLogin_ThrottlingExpires_LogsInWithoutPasswordResetOrReactivation() + public async Task SignupAsync_AccountCreationEnabledWithValidInviteAndInvalidActiveDirectoryAccount_ReturnsUnauthorized() { // 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++) + _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 { - 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 }; + 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)); // 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); + 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() + ); // Assert - Assert.Equal(HttpStatusCode.Unauthorized, blocked.StatusCode); - Assert.NotNull(throttledUser); - Assert.True(throttledUser.IsActive); - Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] - public async Task RemoveExternalLoginAsync_WithLinkedAccount_RemovesAccount() + public async Task SignupAsync_AccountCreationEnabledWithValidInviteAndValidActiveDirectoryAccount_ReturnsToken() { // 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()); + _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)); // Act var result = await SendRequestAsAsync(r => r - .Post() - .AsTestOrganizationUser() - .AppendPaths("auth", "unlink", providerName) - .Content(new ValueFromBody(providerUserId)) - .StatusCodeShouldBeOk() + .Post() + .AppendPath("auth/signup") + .Content(new Signup + { + Name = "Test", + Email = email, + Password = TestDomainLoginProvider.ValidPassword, + InviteToken = invite.Token + }) + .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() + public async Task SignupAsync_AccountCreationEnabledWithValidInvite_CreatesVerifiedUserAndConsumesInvite() { // Arrange - var providerUserId = new ValueFromBody(String.Empty); - - // Act & Assert - return SendRequestAsync(r => r - .Post() - .AsTestOrganizationUser() - .AppendPaths("auth", "unlink", "github") - .Content(providerUserId) - .StatusCodeShouldBeBadRequest() - ); - } + _authOptions.EnableAccountCreation = true; - [Fact] - public async Task ResetPasswordShouldFailWithCurrentPasswordAsync() - { - const string email = "test6@exceptionless.io"; - const string password = "Test6 password"; - const string salt = "1234567890123456"; - string passwordHash = password.ToSaltedHash(salt); + 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 user = new User + var invite = new Invite { - EmailAddress = email, - Password = passwordHash, - Salt = salt, - FullName = "User 6", - Roles = AuthorizationRoles.AllScopes + Token = StringExtensions.GetNewToken(), + EmailAddress = email.ToLowerInvariant(), + DateAdded = DateTime.UtcNow }; - user.MarkEmailAddressVerified(); - user.CreatePasswordResetToken(TimeProvider); - Assert.NotNull(user.PasswordResetToken); - Assert.True(user.PasswordResetTokenExpiration.IsAfter(TimeProvider.GetUtcNow().UtcDateTime)); - - 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.NotEmpty(result.Token); - - 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 problemDetails = await SendRequestAsAsync(r => r - .Post() - .BasicAuthorization(email, password) - .AppendPath("auth/reset-password") - .Content(new ResetPasswordModel - { - PasswordResetToken = user.PasswordResetToken, - Password = password - }) - .StatusCodeShouldBeUnprocessableEntity() - ); - - Assert.NotNull(problemDetails); - Assert.Single(problemDetails.Errors); - Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); + Assert.False(String.IsNullOrEmpty(result.Token)); - Assert.NotNull(await _tokenRepository.GetByIdAsync(result.Token)); - } + await RefreshDataAsync(); - [Fact] - public async Task ResetPassword_MissingRemoteIpAddress_ClearsUserLoginAttempts() - { - // Arrange - var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + var user = await _userRepository.GetByEmailAddressAsync(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++) - { - 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(); - - // 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); - } + 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); - [Theory] - [InlineData(false, HttpStatusCode.Unauthorized)] - [InlineData(true, HttpStatusCode.OK)] - public async Task ResetPassword_PreservesActiveState_OnlyActiveUsersCanLogIn(bool isActive, HttpStatusCode expectedStatus) - { - // Arrange - var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); - Assert.NotNull(user); - user = user with { IsActive = isActive }; - user.CreatePasswordResetToken(TimeProvider); - Assert.NotNull(user.PasswordResetToken); - await _userRepository.SaveAsync(user, options => options.ImmediateConsistency()); - using var client = _server.CreateClient(); - const string newPassword = "Password2$"; + organization = await _organizationRepository.GetByIdAsync(organization.Id); + Assert.NotNull(organization); + Assert.Empty(organization.Invites); - // 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 token = await _tokenRepository.GetByIdAsync(result.Token); + Assert.NotNull(token); + Assert.Equal(user.Id, token.UserId); + Assert.Equal(TokenType.Authentication, token.Type); - // 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); + var mailQueue = GetService>() as InMemoryQueue; + Assert.NotNull(mailQueue); + Assert.Equal(0, (await mailQueue.GetQueueStatsAsync()).Enqueued); } [Fact] - public async Task SignupShouldFailWhenUsingExistingAccountWithNoPasswordOrInvalidPassword() + public async Task SignupAsync_ExistingUserWithMissingOrInvalidPassword_RejectsCredentials() { + // Arrange const string email = "test6@exceptionless.io"; const string password = "Test6 password"; const string salt = "1234567890123456"; @@ -1891,6 +2014,7 @@ public async Task SignupShouldFailWhenUsingExistingAccountWithNoPasswordOrInvali user.MarkEmailAddressVerified(); await _userRepository.AddAsync(user); + // Act var problemDetails = await SendRequestAsAsync(r => r .Post() .AppendPath("auth/signup") @@ -1903,11 +2027,7 @@ public async Task SignupShouldFailWhenUsingExistingAccountWithNoPasswordOrInvali .StatusCodeShouldBeUnprocessableEntity() ); - Assert.NotNull(problemDetails); - Assert.Single(problemDetails.Errors); - Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "password")); - - await SendRequestAsync(r => r + using var invalidPasswordResponse = await SendRequestAsync(r => r .Post() .AppendPath("auth/signup") .Content(new Signup @@ -1918,6 +2038,37 @@ await SendRequestAsync(r => r }) .StatusCodeShouldBeUnauthorized() ); + + // Assert + 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 SignupAsync_MissingPassword_ReturnsValidationError() + { + // Arrange + var signup = new Signup + { + Name = "hello", + Email = "test@domain.com", + Password = null! + }; + + // Act + var problemDetails = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/signup") + .Content(signup) + .StatusCodeShouldBeUnprocessableEntity() + ); + + // Assert + Assert.NotNull(problemDetails); + 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/Extensions/HttpExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs index 0ce06509e5..68d0c73f8b 100644 --- a/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs +++ b/tests/Exceptionless.Tests/Extensions/HttpExtensionsTests.cs @@ -40,10 +40,10 @@ public void GetBasicAuth_NullRequest_ThrowsArgumentNullException() HttpRequest request = null!; // Act - var exception = Assert.Throws(() => HttpExtensions.GetBasicAuth(request)); + var exception = Record.Exception(() => HttpExtensions.GetBasicAuth(request)); // Assert - Assert.Equal("request", exception.ParamName); + Assert.Equal("request", Assert.IsType(exception).ParamName); } [Theory] diff --git a/tests/Exceptionless.Tests/Services/AuthServiceTests.cs b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs index c321fa6958..c87575a049 100644 --- a/tests/Exceptionless.Tests/Services/AuthServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs @@ -32,6 +32,67 @@ public async Task ClearUserLoginAttemptsAsync_Recovery_PreservesIpFailuresAndChe 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)] + [InlineData(true)] + public async Task DisposeAsync_CleanupFailure_PreservesOriginalExceptionAndReleasesOtherCacheKeys(bool cancelled) + { + // 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 attemptedCacheKeys = new List(); + cache.BeforeRemove = cacheKey => + { + attemptedCacheKeys.Add(cacheKey); + if (cacheKey.Contains("user:", StringComparison.Ordinal)) + throw new IOException("Sensitive provider message with user@exceptionless.test and a cache key."); + }; + + // 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); + var cleanupException = Assert.IsType(entry.Exception); + var safeException = Assert.Single(cleanupException.InnerExceptions); + Assert.NotNull(safeException.StackTrace); + Assert.DoesNotContain("Sensitive provider message", cleanupException.ToString()); + Assert.DoesNotContain("user@exceptionless.test", cleanupException.ToString()); + } + [Fact] public async Task DisposeAsync_CompletedFailure_RetainsCharge() { @@ -64,22 +125,16 @@ public async Task DisposeAsync_InterruptedAttempt_ReleasesBothReservations() } [Fact] - public async Task RecordLoginAsync_NullAttempt_Throws() + public async Task RecordLoginFailureAsync_NullAttempt_ThrowsArgumentNullException() { // Arrange var service = GetService(); // Act - var failureException = await Record.ExceptionAsync(() => service.RecordLoginFailureAsync(null!)); - var successException = await Record.ExceptionAsync(() => service.RecordLoginSuccessAsync(null!)); - var cacheException = Record.Exception(() => new AuthService(null!, TimeProvider)); - var timeException = Record.Exception(() => new AuthService(GetService(), null!)); + var exception = await Record.ExceptionAsync(() => service.RecordLoginFailureAsync(null!)); // Assert - Assert.IsType(failureException); - Assert.IsType(successException); - Assert.IsType(cacheException); - Assert.IsType(timeException); + Assert.Equal("attempt", Assert.IsType(exception).ParamName); } [Fact] @@ -104,6 +159,19 @@ public async Task RecordLoginSuccessAsync_ConcurrentFailures_PreservesNewFailure Assert.Null(denied); } + [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() { @@ -158,7 +226,7 @@ public async Task TryBeginLoginAsync_CancelledAfterReservation_ReleasesBothCache cancellation.Cancel(); }; - var service = new AuthService(cache, TimeProvider); + var service = new AuthService(cache, TimeProvider, Log.CreateLogger()); // Act var exception = await Record.ExceptionAsync(async () => @@ -242,9 +310,16 @@ public async Task TryBeginLoginAsync_CleanupFailure_PreservesOriginalExceptionAn var entry = Assert.Single(logger.Entries); Assert.Equal(LogLevel.Error, entry.Level); - Assert.Null(entry.Exception); - Assert.Contains(cancelled ? nameof(OperationCanceledException) : nameof(InvalidOperationException), entry.Message); - Assert.Contains(nameof(IOException), entry.Message); + var cleanupException = Assert.IsType(entry.Exception); + Assert.Equal(cancelled ? 2 : 1, cleanupException.InnerExceptions.Count); + Assert.All(cleanupException.InnerExceptions, failure => + { + Assert.Equal(typeof(IOException).FullName, failure.Data["ExceptionType"]); + Assert.NotNull(failure.StackTrace); + Assert.Null(failure.InnerException); + Assert.DoesNotContain("sensitive", failure.ToString()); + }); + Assert.DoesNotContain("sensitive", entry.Message); Assert.DoesNotContain("user@exceptionless.test", entry.Message); Assert.DoesNotContain("192.0.2.1", entry.Message); @@ -257,7 +332,7 @@ public async Task TryBeginLoginAsync_ConcurrentInstances_BoundsChecksBeforeFailu { // Arrange var first = GetService(); - var second = new AuthService(GetService(), TimeProvider); + var second = new AuthService(GetService(), TimeProvider, Log.CreateLogger()); // Act var attempts = await Task.WhenAll(Enumerable.Range(0, 100).Select(index => @@ -344,7 +419,7 @@ public async Task TryBeginLoginAsync_IpCacheFailure_ReleasesUserCacheKey() throw failure; }; - var service = new AuthService(cache, TimeProvider); + var service = new AuthService(cache, TimeProvider, Log.CreateLogger()); // Act var exception = await Record.ExceptionAsync(async () => From da2f75b13098ba6eeba89de5bedf51ee420002f9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 4 Oct 2026 21:09:22 -0500 Subject: [PATCH 5/9] Use normal exception logging for authentication cleanup --- .../Services/AuthService.cs | 19 +----- .../Security/ApiKeyAuthenticationHandler.cs | 3 + .../Services/AuthServiceTests.cs | 60 +++++++++---------- 3 files changed, 32 insertions(+), 50 deletions(-) diff --git a/src/Exceptionless.Core/Services/AuthService.cs b/src/Exceptionless.Core/Services/AuthService.cs index 82313811d2..0255a627ce 100644 --- a/src/Exceptionless.Core/Services/AuthService.cs +++ b/src/Exceptionless.Core/Services/AuthService.cs @@ -1,4 +1,3 @@ -using System.Runtime.ExceptionServices; using Exceptionless.DateTimeExtensions; using Foundatio.Caching; using Microsoft.Extensions.Logging; @@ -131,31 +130,17 @@ await RemoveFailuresAsync(failures.Where(pair => pair.Value.HasValue && pair.Val /// private async Task ReleaseCacheKeysAsync(IEnumerable cacheKeys, string reservation) { - List? cleanupFailures = null; foreach (string cacheKey in cacheKeys) { try { await _cache.RemoveIfEqualAsync(cacheKey, reservation); } - catch (Exception exception) + catch (Exception ex) { - // Cache-provider messages can contain identities, cache keys, or connection details. - var safeException = new Exception("Cache reservation cleanup failed."); - safeException.Data["ExceptionType"] = exception.GetType().FullName; - if (!String.IsNullOrEmpty(exception.StackTrace)) - ExceptionDispatchInfo.SetRemoteStackTrace(safeException, exception.StackTrace); - - (cleanupFailures ??= []).Add(safeException); + _logger.LogError(ex, "Error releasing login admission reservation: {Message}", ex.Message); } } - - if (cleanupFailures is null) - return; - - _logger.LogError(new AggregateException("Login admission cleanup failed.", cleanupFailures), - "Failed to release {FailedCacheKeyCount} login admission reservations: {Message}", cleanupFailures.Count, - "Unreleased reservations expire at the current window boundary."); } private Task RemoveFailuresAsync(IEnumerable> failures) diff --git a/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs index a5ecaa4ee3..ac4885f1bf 100644 --- a/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs +++ b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs @@ -188,6 +188,9 @@ private async Task AuthenticatePasswordAsync(AuthInfo authIn } 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); } diff --git a/tests/Exceptionless.Tests/Services/AuthServiceTests.cs b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs index c87575a049..be2f341452 100644 --- a/tests/Exceptionless.Tests/Services/AuthServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs @@ -51,9 +51,11 @@ public void Constructor_NullDependency_ThrowsArgumentNullException() } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task DisposeAsync_CleanupFailure_PreservesOriginalExceptionAndReleasesOtherCacheKeys(bool cancelled) + [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); @@ -62,12 +64,15 @@ public async Task DisposeAsync_CleanupFailure_PreservesOriginalExceptionAndRelea 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)) - throw new IOException("Sensitive provider message with user@exceptionless.test and a cache key."); + if (!cacheKey.Contains("user:", StringComparison.Ordinal)) + return null; + + return asynchronousCleanupFailure ? Task.FromException(cleanupFailure) : throw cleanupFailure; }; // Act @@ -86,11 +91,8 @@ public async Task DisposeAsync_CleanupFailure_PreservesOriginalExceptionAndRelea Assert.All(ipAttempts, Assert.NotNull); var entry = Assert.Single(logger.Entries); Assert.Equal(LogLevel.Error, entry.Level); - var cleanupException = Assert.IsType(entry.Exception); - var safeException = Assert.Single(cleanupException.InnerExceptions); - Assert.NotNull(safeException.StackTrace); - Assert.DoesNotContain("Sensitive provider message", cleanupException.ToString()); - Assert.DoesNotContain("user@exceptionless.test", cleanupException.ToString()); + Assert.Same(cleanupFailure, entry.Exception); + Assert.Equal($"Error releasing login admission reservation: {cleanupFailure.Message}", entry.Message); } [Fact] @@ -263,15 +265,17 @@ public async Task TryBeginLoginAsync_CancelledRequest_Throws() } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task TryBeginLoginAsync_CleanupFailure_PreservesOriginalExceptionAndLogsSafeDetails(bool cancelled) + [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 sensitive acquisition detail."); + var failure = new InvalidOperationException("Synthetic acquisition failure."); cache.BeforeAdd = cacheKey => { if (!cancelled && cacheKey.Contains("ip:", StringComparison.Ordinal)) @@ -284,7 +288,8 @@ public async Task TryBeginLoginAsync_CleanupFailure_PreservesOriginalExceptionAn cancellation.Cancel(); }; - cache.BeforeRemove = _ => throw new IOException("Synthetic sensitive cleanup detail."); + 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); @@ -308,21 +313,14 @@ public async Task TryBeginLoginAsync_CleanupFailure_PreservesOriginalExceptionAn else Assert.Same(failure, exception); - var entry = Assert.Single(logger.Entries); - Assert.Equal(LogLevel.Error, entry.Level); - var cleanupException = Assert.IsType(entry.Exception); - Assert.Equal(cancelled ? 2 : 1, cleanupException.InnerExceptions.Count); - Assert.All(cleanupException.InnerExceptions, failure => + Assert.Equal(cancelled ? 2 : 1, logger.Entries.Count); + Assert.All(logger.Entries, entry => { - Assert.Equal(typeof(IOException).FullName, failure.Data["ExceptionType"]); - Assert.NotNull(failure.StackTrace); - Assert.Null(failure.InnerException); - Assert.DoesNotContain("sensitive", failure.ToString()); + Assert.Equal(LogLevel.Error, entry.Level); + Assert.Same(cleanupFailure, entry.Exception); + Assert.Equal($"Error releasing login admission reservation: {cleanupFailure.Message}", entry.Message); }); - Assert.DoesNotContain("sensitive", entry.Message); - Assert.DoesNotContain("user@exceptionless.test", entry.Message); - Assert.DoesNotContain("192.0.2.1", entry.Message); Assert.Equal(4, beforeExpiration.Count(attempt => attempt is not null)); Assert.All(afterExpiration, Assert.NotNull); } @@ -498,7 +496,7 @@ private sealed class FaultingCacheClient(TimeProvider timeProvider) : InMemoryCa { public Action? BeforeAdd { get; set; } public Action? AfterAdd { get; set; } - public Action? BeforeRemove { get; set; } + public Func?>? BeforeRemove { get; set; } async Task ICacheClient.AddAsync(string cacheKey, T value, TimeSpan? expiresIn) { @@ -512,10 +510,6 @@ async Task ICacheClient.AddAsync(string cacheKey, T value, TimeSpan? ex } Task ICacheClient.RemoveIfEqualAsync(string cacheKey, T expected) - { - BeforeRemove?.Invoke(cacheKey); - - return base.RemoveIfEqualAsync(cacheKey, expected); - } + => BeforeRemove?.Invoke(cacheKey) ?? base.RemoveIfEqualAsync(cacheKey, expected); } } From 3a210471fb2dc751cf4455e763dc91e8a9cf763e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 5 Oct 2026 18:59:44 -0500 Subject: [PATCH 6/9] fix auth admission cache coordination --- .../Services/AuthService.cs | 57 +++++++++++++++++-- .../Services/AuthServiceTests.cs | 54 ++++++++++++++++++ 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/src/Exceptionless.Core/Services/AuthService.cs b/src/Exceptionless.Core/Services/AuthService.cs index 0255a627ce..c0ad850c6c 100644 --- a/src/Exceptionless.Core/Services/AuthService.cs +++ b/src/Exceptionless.Core/Services/AuthService.cs @@ -1,6 +1,7 @@ using Exceptionless.DateTimeExtensions; using Foundatio.Caching; using Microsoft.Extensions.Logging; +using System.Runtime.CompilerServices; namespace Exceptionless.Core.Services; @@ -12,7 +13,9 @@ 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 ConditionalWeakTable InMemoryMutationLocks = new(); private readonly ScopedCacheClient _cache; + private readonly SemaphoreSlim? _inMemoryMutationLock; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; @@ -23,6 +26,7 @@ public AuthService(ICacheClient cacheClient, TimeProvider timeProvider, ILogger< ArgumentNullException.ThrowIfNull(logger); _cache = new ScopedCacheClient(cacheClient, "Auth"); + _inMemoryMutationLock = cacheClient is InMemoryCacheClient ? InMemoryMutationLocks.GetValue(cacheClient, _ => new SemaphoreSlim(1, 1)) : null; _timeProvider = timeProvider; _logger = logger; } @@ -87,7 +91,7 @@ public async Task RecordLoginFailureAsync(LoginAttempt attempt) // 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 => _cache.ReplaceIfEqualAsync(cacheKey, $"failed:{attempt.Reservation}", attempt.Reservation, remaining))); + await Task.WhenAll(attempt.CacheKeys.Select(cacheKey => ReplaceIfEqualAsync(cacheKey, $"failed:{attempt.Reservation}", attempt.Reservation, remaining))); } public async Task RecordLoginSuccessAsync(LoginAttempt attempt) @@ -118,7 +122,7 @@ await RemoveFailuresAsync(failures.Where(pair => pair.Value.HasValue && pair.Val private async Task ReserveCacheKeyAsync(string[] cacheKeys, string reservation, DateTime expiresUtc) { foreach (string cacheKey in cacheKeys) - if (await _cache.AddAsync(cacheKey, reservation, expiresUtc)) + if (await AddAsync(cacheKey, reservation, expiresUtc)) return cacheKey; return null; @@ -134,7 +138,7 @@ private async Task ReleaseCacheKeysAsync(IEnumerable cacheKeys, string r { try { - await _cache.RemoveIfEqualAsync(cacheKey, reservation); + await RemoveIfEqualAsync(cacheKey, reservation); } catch (Exception ex) { @@ -144,7 +148,52 @@ private async Task ReleaseCacheKeysAsync(IEnumerable cacheKeys, string r } private Task RemoveFailuresAsync(IEnumerable> failures) - => Task.WhenAll(failures.Select(failure => _cache.RemoveIfEqualAsync(failure.Key, failure.Value))); + => Task.WhenAll(failures.Select(failure => RemoveIfEqualAsync(failure.Key, failure.Value))); + + private Task AddAsync(string cacheKey, string value, DateTime expiresUtc) + => ExecuteInMemoryMutationAsync(() => _cache.AddAsync(cacheKey, value, expiresUtc)); + + 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) + { + if (_inMemoryMutationLock is null) + return await action(); + + await _inMemoryMutationLock.WaitAsync(); + try + { + return await action(); + } + finally + { + _inMemoryMutationLock.Release(); + } + } private DateTime GetWindowExpiration() => _timeProvider.GetUtcNow().UtcDateTime.Floor(AttemptWindow).Add(AttemptWindow); diff --git a/tests/Exceptionless.Tests/Services/AuthServiceTests.cs b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs index be2f341452..ed6c32e0e9 100644 --- a/tests/Exceptionless.Tests/Services/AuthServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/AuthServiceTests.cs @@ -161,6 +161,35 @@ public async Task RecordLoginSuccessAsync_ConcurrentFailures_PreservesNewFailure 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() { @@ -511,5 +540,30 @@ async Task ICacheClient.AddAsync(string cacheKey, T value, TimeSpan? ex 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); + } } } From a1211315fc60aa6382b4a24cb59fcb961f919c3f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 5 Oct 2026 20:42:38 -0500 Subject: [PATCH 7/9] Preserve failed login reservations and make completion idempotent --- .../Services/AuthService.cs | 26 +- .../Services/AuthServiceReliabilityTests.cs | 268 ++++++++++++++++++ 2 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 tests/Exceptionless.Tests/Services/AuthServiceReliabilityTests.cs diff --git a/src/Exceptionless.Core/Services/AuthService.cs b/src/Exceptionless.Core/Services/AuthService.cs index c0ad850c6c..365c7a6241 100644 --- a/src/Exceptionless.Core/Services/AuthService.cs +++ b/src/Exceptionless.Core/Services/AuthService.cs @@ -1,7 +1,7 @@ +using System.Runtime.CompilerServices; using Exceptionless.DateTimeExtensions; using Foundatio.Caching; using Microsoft.Extensions.Logging; -using System.Runtime.CompilerServices; namespace Exceptionless.Core.Services; @@ -26,6 +26,14 @@ public AuthService(ICacheClient cacheClient, TimeProvider timeProvider, ILogger< 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; @@ -85,6 +93,11 @@ 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; @@ -98,6 +111,9 @@ public async Task RecordLoginSuccessAsync(LoginAttempt attempt) { ArgumentNullException.ThrowIfNull(attempt); + if (!attempt.TryComplete()) + return; + await ReleaseCacheKeysAsync(attempt.CacheKeys, attempt.Reservation); await RemoveFailuresAsync(attempt.ObservedFailures); } @@ -220,11 +236,13 @@ private static string[] GetIpAddressCacheKeys(string ipAddress, DateTime expires => Enumerable.Range(0, IpAddressFailureLimit).Select(index => $"ip:{ipAddress}:attempts:{expiresUtc.Ticks}:{index}").ToArray(); /// - /// Owns one login admission reservation and releases unfinished entries when disposed. + /// 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) { @@ -240,6 +258,8 @@ internal LoginAttempt(DateTime expiresUtc, string[] cacheKeys, string reservatio internal string Reservation { get; } internal KeyValuePair[] ObservedFailures { get; } - public ValueTask DisposeAsync() => new(_releaseAsync()); + internal bool TryComplete() => Interlocked.CompareExchange(ref _completed, 1, 0) == 0; + + public ValueTask DisposeAsync() => TryComplete() ? new(_releaseAsync()) : ValueTask.CompletedTask; } } 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; + } + } +} From 205377c21fc17e53e1c3420e68996bd9b7652feb Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 5 Oct 2026 20:58:08 -0500 Subject: [PATCH 8/9] Allow bounded waits for concurrent Basic password authentication --- .../Services/AuthService.cs | 74 ++++++-- .../Security/ApiKeyAuthenticationHandler.cs | 2 +- .../BasicPasswordAuthenticationTests.cs | 169 ++++++++++++++++++ .../Services/AuthServiceAdmissionTests.cs | 166 +++++++++++++++++ 4 files changed, 399 insertions(+), 12 deletions(-) create mode 100644 tests/Exceptionless.Tests/Security/BasicPasswordAuthenticationTests.cs create mode 100644 tests/Exceptionless.Tests/Services/AuthServiceAdmissionTests.cs diff --git a/src/Exceptionless.Core/Services/AuthService.cs b/src/Exceptionless.Core/Services/AuthService.cs index 365c7a6241..b5a14ccb6c 100644 --- a/src/Exceptionless.Core/Services/AuthService.cs +++ b/src/Exceptionless.Core/Services/AuthService.cs @@ -13,6 +13,8 @@ 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; @@ -40,6 +42,53 @@ public AuthService(ICacheClient cacheClient, TimeProvider timeProvider, ILogger< } 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(); + var result = await TryBeginLoginCoreAsync(emailAddress, ipAddress, cancellationToken); + while (result.Attempt is null && result.BlockedCacheKeys is not null) + { + if (!await WaitForAvailableSlotAsync(result.BlockedCacheKeys, started, cancellationToken)) + return null; + + result = await TryBeginLoginCoreAsync(emailAddress, ipAddress, cancellationToken); + } + + return result.Attempt; + } + + private async Task WaitForAvailableSlotAsync(string[] cacheKeys, long started, CancellationToken cancellationToken) + { + while (_timeProvider.GetElapsedTime(started) < AdmissionWaitTimeout) + { + cancellationToken.ThrowIfCancellationRequested(); + var entries = await _cache.GetAllAsync(cacheKeys); + var remaining = AdmissionWaitTimeout - _timeProvider.GetElapsedTime(started); + if (remaining <= TimeSpan.Zero) + return false; + + if (cacheKeys.Any(key => !entries.TryGetValue(key, out var value) || !value.HasValue)) + return true; + + 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); @@ -57,19 +106,20 @@ public AuthService(ICacheClient cacheClient, TimeProvider timeProvider, ILogger< var reservedCacheKeys = new List(2); try { - string? userCacheKey = await ReserveCacheKeyAsync(userCacheKeys, reservation, expiresUtc); + string? userCacheKey = await ReserveCacheKeyAsync(userCacheKeys, reservation, expiresUtc, cancellationToken); if (userCacheKey is null) - return null; + return (null, userCacheKeys); reservedCacheKeys.Add(userCacheKey); if (ipAddress is not null) { - string? ipAddressCacheKey = await ReserveCacheKeyAsync(GetIpAddressCacheKeys(ipAddress, expiresUtc), reservation, expiresUtc); + string[] ipCacheKeys = GetIpAddressCacheKeys(ipAddress, expiresUtc); + string? ipAddressCacheKey = await ReserveCacheKeyAsync(ipCacheKeys, reservation, expiresUtc, cancellationToken); if (ipAddressCacheKey is null) { await ReleaseCacheKeysAsync(reservedCacheKeys, reservation); - return null; + return (null, ipCacheKeys); } reservedCacheKeys.Add(ipAddressCacheKey); @@ -79,7 +129,7 @@ public AuthService(ICacheClient cacheClient, TimeProvider timeProvider, ILogger< string[] cacheKeys = reservedCacheKeys.ToArray(); - return new LoginAttempt(expiresUtc, cacheKeys, reservation, observedFailures, () => ReleaseCacheKeysAsync(cacheKeys, reservation)); + return (new LoginAttempt(expiresUtc, cacheKeys, reservation, observedFailures, () => ReleaseCacheKeysAsync(cacheKeys, reservation)), null); } catch { @@ -134,11 +184,12 @@ await RemoveFailuresAsync(failures.Where(pair => pair.Value.HasValue && pair.Val /// 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) + private async Task ReserveCacheKeyAsync(string[] cacheKeys, string reservation, DateTime expiresUtc, CancellationToken cancellationToken) { foreach (string cacheKey in cacheKeys) - if (await AddAsync(cacheKey, reservation, expiresUtc)) + if (await AddAsync(cacheKey, reservation, expiresUtc, cancellationToken)) return cacheKey; return null; @@ -166,8 +217,8 @@ private async Task ReleaseCacheKeysAsync(IEnumerable cacheKeys, string r private Task RemoveFailuresAsync(IEnumerable> failures) => Task.WhenAll(failures.Select(failure => RemoveIfEqualAsync(failure.Key, failure.Value))); - private Task AddAsync(string cacheKey, string value, DateTime expiresUtc) - => ExecuteInMemoryMutationAsync(() => _cache.AddAsync(cacheKey, value, expiresUtc)); + 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) { @@ -195,12 +246,13 @@ private Task RemoveIfEqualAsync(string cacheKey, string expected) }); } - private async Task ExecuteInMemoryMutationAsync(Func> action) + private async Task ExecuteInMemoryMutationAsync(Func> action, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); if (_inMemoryMutationLock is null) return await action(); - await _inMemoryMutationLock.WaitAsync(); + await _inMemoryMutationLock.WaitAsync(cancellationToken); try { return await action(); diff --git a/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs index ac4885f1bf..6dea7c7a46 100644 --- a/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs +++ b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs @@ -174,7 +174,7 @@ private async Task AuthenticatePasswordAsync(AuthInfo authIn { string emailAddress = authInfo.Username.Trim().ToLowerInvariant(); string? ipAddress = Request.GetClientIpAddress(); - await using var loginAttempt = await _authService.TryBeginLoginAsync(emailAddress, ipAddress, Context.RequestAborted); + await using var loginAttempt = await _authService.WaitForLoginAsync(emailAddress, ipAddress, Context.RequestAborted); if (loginAttempt is null) { Logger.LogError("Login denied for {EmailAddress}", emailAddress); diff --git a/tests/Exceptionless.Tests/Security/BasicPasswordAuthenticationTests.cs b/tests/Exceptionless.Tests/Security/BasicPasswordAuthenticationTests.cs new file mode 100644 index 0000000000..2c06cba5f7 --- /dev/null +++ b/tests/Exceptionless.Tests/Security/BasicPasswordAuthenticationTests.cs @@ -0,0 +1,169 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Reflection; +using System.Text; +using System.Text.Encodings.Web; +using Exceptionless.Core; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +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); + var repository = DispatchProxy.Create(); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + string passwordHash = Password.ToSaltedHash(Salt); + int lookups = 0; + int requestCount = limit + 1; + ((UserRepositoryProxy)(object)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); + var repository = DispatchProxy.Create(); + Exception failure = cancellationException ? new OperationCanceledException(cancellation.Token) : new IOException("Synthetic repository failure."); + ((UserRepositoryProxy)(object)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)); + } + + 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 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 class UserRepositoryProxy : DispatchProxy + { + public Func> Lookup { get; set; } = null!; + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod?.Name == nameof(IUserRepository.GetByEmailAddressAsync)) + return Lookup((string)args![0]!); + + throw new NotSupportedException($"Unexpected repository call: {targetMethod?.Name}"); + } + } +} diff --git a/tests/Exceptionless.Tests/Services/AuthServiceAdmissionTests.cs b/tests/Exceptionless.Tests/Services/AuthServiceAdmissionTests.cs new file mode 100644 index 0000000000..744627ffa8 --- /dev/null +++ b/tests/Exceptionless.Tests/Services/AuthServiceAdmissionTests.cs @@ -0,0 +1,166 @@ +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; + + [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_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))); +} From 6c9d3624205d5f42db964538273c742296ba3f99 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 5 Oct 2026 22:07:48 -0500 Subject: [PATCH 9/9] Bound admission retries and correct Basic authentication test setup --- .../Services/AuthService.cs | 25 +++- .../BasicPasswordAuthenticationTests.cs | 32 +++-- .../Services/AuthServiceAdmissionTests.cs | 118 ++++++++++++++++++ 3 files changed, 159 insertions(+), 16 deletions(-) diff --git a/src/Exceptionless.Core/Services/AuthService.cs b/src/Exceptionless.Core/Services/AuthService.cs index b5a14ccb6c..fc1509674f 100644 --- a/src/Exceptionless.Core/Services/AuthService.cs +++ b/src/Exceptionless.Core/Services/AuthService.cs @@ -53,30 +53,49 @@ public AuthService(ICacheClient cacheClient, TimeProvider timeProvider, ILogger< 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, cancellationToken)) + 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, CancellationToken cancellationToken) + 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)) - return true; + { + // 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; diff --git a/tests/Exceptionless.Tests/Security/BasicPasswordAuthenticationTests.cs b/tests/Exceptionless.Tests/Security/BasicPasswordAuthenticationTests.cs index 2c06cba5f7..2ef56a0e5c 100644 --- a/tests/Exceptionless.Tests/Security/BasicPasswordAuthenticationTests.cs +++ b/tests/Exceptionless.Tests/Security/BasicPasswordAuthenticationTests.cs @@ -1,14 +1,16 @@ using System.Collections.Concurrent; using System.Net; -using System.Reflection; 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; @@ -38,12 +40,12 @@ public async Task AuthenticateAsync_ConcurrentBasicRequests_PreservesValidBursts 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); - var repository = DispatchProxy.Create(); + using var repository = CreateUserRepository(); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); string passwordHash = Password.ToSaltedHash(Salt); int lookups = 0; int requestCount = limit + 1; - ((UserRepositoryProxy)(object)repository).Lookup = async email => + repository.Lookup = async email => { Interlocked.Increment(ref lookups); await release.Task.WaitAsync(TestTimeout, TestCancellationToken); @@ -91,9 +93,9 @@ public async Task AuthenticateAsync_RepositoryException_LogsUnexpectedFailureAnd using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken); using var logger = new CapturingLoggerFactory(); var service = new AuthService(cache, System.TimeProvider.System, NullLogger.Instance); - var repository = DispatchProxy.Create(); + using var repository = CreateUserRepository(); Exception failure = cancellationException ? new OperationCanceledException(cancellation.Token) : new IOException("Synthetic repository failure."); - ((UserRepositoryProxy)(object)repository).Lookup = _ => + repository.Lookup = _ => { if (cancelRequest) cancellation.Cancel(); @@ -112,6 +114,12 @@ public async Task AuthenticateAsync_RepositoryException_LogsUnexpectedFailureAnd 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) { @@ -131,6 +139,9 @@ private async Task AuthenticateAsync(string emailAddress, st return await handler.AuthenticateAsync(); } + private TestUserRepository CreateUserRepository() + => new(GetService(), GetService(), GetService()); + private sealed class CapturingLoggerFactory : ILoggerFactory, ILogger { public ConcurrentQueue Errors { get; } = new(); @@ -154,16 +165,11 @@ private sealed class TestOptionsMonitor : IOptionsMonitor listener) => null; } - private class UserRepositoryProxy : DispatchProxy + private sealed class TestUserRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options) + : UserRepository(configuration, validator, options), IUserRepository { public Func> Lookup { get; set; } = null!; - protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) - { - if (targetMethod?.Name == nameof(IUserRepository.GetByEmailAddressAsync)) - return Lookup((string)args![0]!); - - throw new NotSupportedException($"Unexpected repository call: {targetMethod?.Name}"); - } + Task IUserRepository.GetByEmailAddressAsync(string emailAddress) => Lookup(emailAddress); } } diff --git a/tests/Exceptionless.Tests/Services/AuthServiceAdmissionTests.cs b/tests/Exceptionless.Tests/Services/AuthServiceAdmissionTests.cs index 744627ffa8..f04e14e22d 100644 --- a/tests/Exceptionless.Tests/Services/AuthServiceAdmissionTests.cs +++ b/tests/Exceptionless.Tests/Services/AuthServiceAdmissionTests.cs @@ -13,6 +13,39 @@ public sealed class AuthServiceAdmissionTests 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)] @@ -126,6 +159,65 @@ public async Task WaitForLoginAsync_PendingChecksNeverComplete_StopsAtDeadlineOr 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() { @@ -163,4 +255,30 @@ private static Task DisposeAsync(IEnumerable attempts 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; + } + } }