Skip to content
Merged
14 changes: 13 additions & 1 deletion src/Exceptionless.Core/Extensions/UserExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
using Exceptionless.Core.Models;
using System.Diagnostics.CodeAnalysis;
using Exceptionless.Core.Models;
using Exceptionless.DateTimeExtensions;

namespace Exceptionless.Core.Extensions;

public static class UserExtensions
{
/// <summary>
/// Checks whether an active user's session generation matches the presented credential.
/// A missing generation matches only another missing generation for legacy credentials.
/// This method does not load or refresh the user.
/// </summary>
public static bool HasValidAuthenticationVersion([NotNullWhen(true)] this User? user, string? authenticationVersion)
{
return user is { IsActive: true }
&& String.Equals(user.AuthenticationVersion, authenticationVersion, StringComparison.Ordinal);
}

public static bool IsCorrectPassword(this User user, string password)
{
if (String.IsNullOrEmpty(user.Salt) || String.IsNullOrEmpty(user.Password))
Expand Down
6 changes: 6 additions & 0 deletions src/Exceptionless.Core/Models/OAuthToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ public class OAuthToken : IIdentity, IHaveDates, IValidatableObject
[MaxLength(100)]
public string AccessTokenHash { get; set; } = null!;

/// <summary>
/// The user session generation captured when this OAuth token is issued.
/// A legacy null value matches only an active user whose generation is also null; it does not describe a password format.
/// </summary>
public string? AuthenticationVersion { get; set; }

[MaxLength(100)]
public string? RefreshTokenHash { get; set; }

Expand Down
6 changes: 6 additions & 0 deletions src/Exceptionless.Core/Models/Token.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ public class Token : IOwnedByOrganizationAndProjectWithIdentity, IHaveDates, IVa
[Required]
public string Id { get; set; } = null!;

/// <summary>
/// The user session generation captured when this token is issued.
/// A legacy null value matches only an active user whose generation is also null; it does not describe a password format.
/// </summary>
public string? AuthenticationVersion { get; set; }

/// <summary>
/// Null for user-scoped tokens (where UserId is set instead).
/// Required when ProjectId is set.
Expand Down
7 changes: 7 additions & 0 deletions src/Exceptionless.Core/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ public record User : IIdentity, IHaveDates, IValidatableObject
/// </summary>
public ISet<string> OrganizationIds { get; init; } = new HashSet<string>();

/// <summary>
/// Session revocation generation, rotated when a password changes or resets, or an external login is removed or transferred away.
/// Remains null until rotation; active users with a null generation accept only matching legacy null token generations.
/// This value does not describe the password hashing format.
/// </summary>
public string? AuthenticationVersion { get; set; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent stale user saves from restoring revoked generations

When token cleanup fails—the case this generation is intended to protect—an already-authenticated request can retain the pre-rotation User and later overwrite this property through an unrelated whole-document save. For example, OrganizationHandler.AddModelAsync captures the request user before awaiting organization creation and then calls SaveAsync(user); if a password change completes between those operations, that save restores the old/null generation and makes the surviving old tokens valid again. Persist the generation separately, use partial updates for unrelated user mutations, or enforce optimistic concurrency so stale user documents cannot undo revocation.

AGENTS.md reference: AGENTS.md:L79-L82

Useful? React with 👍 / 👎.


public string? Password { get; set; }
public string? Salt { get; set; }
public string? PasswordResetToken { get; set; }
Expand Down
26 changes: 19 additions & 7 deletions src/Exceptionless.Core/Services/OAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -383,16 +383,22 @@ public async Task<OAuthValidationResult> ValidateAuthorizationRequestAsync(OAuth
return OAuthValidationResult.Valid(client, requestedScopes);
}

public async Task<string> CreateAuthorizationCodeAsync(OAuthAuthorizeRequest request, string userId, IReadOnlyCollection<string> organizationIds)
public async Task<string> CreateAuthorizationCodeAsync(OAuthAuthorizeRequest request, User user, IReadOnlyCollection<string> organizationIds)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(user);
ArgumentNullException.ThrowIfNull(organizationIds);
ArgumentException.ThrowIfNullOrWhiteSpace(user.Id);

await oauthApplicationRepository.AddOrganizationIdsAsync(request.ClientId, organizationIds, o => o.ImmediateConsistency().Notifications(false));

string code = StringExtensions.GetNewToken();
var authorizationCode = new OAuthAuthorizationCode
{
ClientId = request.ClientId,
RedirectUri = request.RedirectUri,
UserId = userId,
UserId = user.Id,
AuthenticationVersion = user.AuthenticationVersion,
CodeChallenge = request.CodeChallenge,
Resource = request.Resource ?? throw new InvalidOperationException("OAuth resource must be validated before creating an authorization code."),
Scopes = NormalizeScopes(request.Scope),
Expand Down Expand Up @@ -432,7 +438,11 @@ public async Task<OAuthTokenIssueResult> ExchangeAuthorizationCodeAsync(OAuthTok
if (!ValidateCodeVerifier(code.CodeChallenge, request.CodeVerifier))
return OAuthTokenIssueResult.Invalid("invalid_grant", "Invalid PKCE verifier.");

return OAuthTokenIssueResult.Success(await CreateTokenAsync(code.UserId, code.ClientId, code.Resource, code.Scopes, code.OrganizationIds));
var user = await userRepository.GetByIdAsync(code.UserId);
if (!user.HasValidAuthenticationVersion(code.AuthenticationVersion))
return OAuthTokenIssueResult.Invalid("invalid_grant", "Authorization code is invalid or expired.");

return OAuthTokenIssueResult.Success(await CreateTokenAsync(code.UserId, code.ClientId, code.Resource, code.Scopes, code.OrganizationIds, authenticationVersion: code.AuthenticationVersion));
}

public async Task<OAuthTokenIssueResult> RefreshAsync(OAuthTokenRequest request)
Expand Down Expand Up @@ -473,8 +483,8 @@ public async Task<OAuthTokenIssueResult> RefreshAsync(OAuthTokenRequest request)
return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid.");
}

var user = await userRepository.GetByIdAsync(token.UserId, o => o.ImmediateConsistency());
if (user is null || !user.IsActive)
var user = await userRepository.GetByIdAsync(token.UserId);
if (!user.HasValidAuthenticationVersion(token.AuthenticationVersion))
{
await RevokeOAuthGrantFamilyAsync(token);
return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid.");
Expand All @@ -490,7 +500,7 @@ public async Task<OAuthTokenIssueResult> RefreshAsync(OAuthTokenRequest request)
if (!token.IsDisabled)
await SpendRefreshTokenAsync(token, utcNow);

return OAuthTokenIssueResult.Success(await CreateTokenAsync(token.UserId, token.ClientId, token.Resource, scopeValidation.Scopes, activeOrganizationIds, token.GrantId));
return OAuthTokenIssueResult.Success(await CreateTokenAsync(token.UserId, token.ClientId, token.Resource, scopeValidation.Scopes, activeOrganizationIds, token.GrantId, token.AuthenticationVersion));
}

public async Task<bool> RevokeAsync(string? tokenValue, string? clientId)
Expand Down Expand Up @@ -590,7 +600,7 @@ private Task DisableTokenAsync(OAuthToken token)
return oauthTokenRepository.SaveAsync(token, o => o.ImmediateConsistency());
}

private async Task<OAuthTokenResponse> CreateTokenAsync(string userId, string clientId, string resource, IReadOnlyCollection<string> scopes, IReadOnlyCollection<string> organizationIds, string? grantId = null)
private async Task<OAuthTokenResponse> CreateTokenAsync(string userId, string clientId, string resource, IReadOnlyCollection<string> scopes, IReadOnlyCollection<string> organizationIds, string? grantId = null, string? authenticationVersion = null)
{
var utcNow = timeProvider.GetUtcNow().UtcDateTime;
var accessToken = CreateOAuthToken();
Expand All @@ -599,6 +609,7 @@ private async Task<OAuthTokenResponse> CreateTokenAsync(string userId, string cl
{
Id = ObjectId.GenerateNewId().ToString(),
UserId = userId,
AuthenticationVersion = authenticationVersion,
ClientId = clientId,
GrantId = String.IsNullOrWhiteSpace(grantId) ? StringExtensions.GetNewToken() : grantId,
Resource = resource,
Expand Down Expand Up @@ -868,6 +879,7 @@ public record OAuthAuthorizationCode
public required string UserId { get; init; }
public required string CodeChallenge { get; init; }
public required string Resource { get; init; }
public string? AuthenticationVersion { get; init; }
public IReadOnlyCollection<string> Scopes { get; set; } = [];
public DateTime CreatedUtc { get; init; }
public IReadOnlyCollection<string> OrganizationIds { get; init; } = [];
Expand Down
33 changes: 28 additions & 5 deletions src/Exceptionless.Web/Api/Handlers/AuthHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,10 @@ public async Task<Result<TokenResult>> Handle(RemoveExternalLogin message)
try
{
if (user.RemoveOAuthAccount(message.ProviderName, message.ProviderUserId.Value))
{
user.AuthenticationVersion = Core.Extensions.StringExtensions.GetNewToken();
await userRepository.SaveAsync(user, o => o.Cache());
Comment thread
niemyjski marked this conversation as resolved.
}
}
catch (Exception ex)
{
Expand Down Expand Up @@ -501,6 +504,7 @@ private async Task AddGlobalAdminRoleIfFirstUserAsync(User user)
private async Task<Result<TokenResult>> ExternalLoginAsync(ExternalAuthInfo authInfo, HttpContext httpContext, string? appId, string? appSecret, Func<ExternalAuthInfo, string, string, Task<UserInfo>> getUserInfoAsync)
{
using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").SetHttpContext(httpContext));

if (String.IsNullOrEmpty(appId) || String.IsNullOrEmpty(appSecret))
throw new ConfigurationErrorsException("Missing Configuration for OAuth provider");

Expand Down Expand Up @@ -552,12 +556,14 @@ private async Task<User> FromExternalLoginAsync(UserInfo userInfo, string? invit
var currentUser = httpContext.Request.GetUser();
if (existingUser is not null)
{
if (existingUser.Id != currentUser.Id)
if (!String.Equals(existingUser.Id, currentUser.Id, StringComparison.Ordinal))
{
if (!existingUser.RemoveOAuthAccount(userInfo.ProviderName, userInfo.Id))
throw new Exception($"Unable to remove existing oauth account for existing user: {existingUser.EmailAddress}");

existingUser.AuthenticationVersion = Core.Extensions.StringExtensions.GetNewToken();
await userRepository.SaveAsync(existingUser, o => o.Cache());
await ResetUserTokensAsync(existingUser, "Link external account to another user", httpContext);
}
else
{
Expand Down Expand Up @@ -621,6 +627,7 @@ private async Task AddInvitedUserToOrganizationAsync(string? token, User user, H
return;

using var _ = logger.BeginScope(new ExceptionlessState().Tag("Invite").Identity(user.EmailAddress).SetHttpContext(httpContext));

var organization = await organizationRepository.GetByInviteTokenAsync(token);
var invite = organization?.GetInvite(token);
if (organization is null || invite is null)
Expand Down Expand Up @@ -650,10 +657,12 @@ private async Task AddInvitedUserToOrganizationAsync(string? token, User user, H
private async Task ChangePasswordAsync(User user, string password, string tag, HttpContext httpContext)
{
using var _ = logger.BeginScope(new ExceptionlessState().Tag(tag).Identity(user.EmailAddress).SetHttpContext(httpContext));

if (String.IsNullOrEmpty(user.Salt))
user.Salt = Core.Extensions.StringExtensions.GetNewToken();

user.Password = password.ToSaltedHash(user.Salt);
user.AuthenticationVersion = Core.Extensions.StringExtensions.GetNewToken();
user.ResetPasswordResetToken();

try
Expand All @@ -671,31 +680,45 @@ private async Task ChangePasswordAsync(User user, string password, string tag, H
private async Task ResetUserTokensAsync(User user, string tag, HttpContext httpContext)
{
using var _ = logger.BeginScope(new ExceptionlessState().Tag(tag).Identity(user.EmailAddress).SetHttpContext(httpContext));

long total = 0;
try
{
total += await tokenRepository.RemoveAllByUserIdAsync(user.Id);
}
catch (Exception ex)
{
logger.LogCritical(ex, "Error removing user tokens for {EmailAddress} ({UserId}): {Message}", user.EmailAddress, user.Id, ex.Message);
}

try
{
long total = await tokenRepository.RemoveAllByUserIdAsync(user.Id);
total += await oauthTokenRepository.RemoveAllByUserIdAsync(user.Id);
logger.RemovedUserTokens(total, user.EmailAddress);
}
catch (Exception ex)
{
logger.LogCritical(ex, "Error removing user tokens for {EmailAddress}: {Message}", user.EmailAddress, ex.Message);
logger.LogCritical(ex, "Error removing OAuth tokens for {EmailAddress} ({UserId}): {Message}", user.EmailAddress, user.Id, ex.Message);
}

logger.RemovedUserTokens(total, user.EmailAddress);
}

private async Task<string> GetOrCreateAuthenticationTokenAsync(User user)
{
var userTokens = await tokenRepository.GetByTypeAndUserIdAsync(TokenType.Authentication, user.Id);

var utcNow = timeProvider.GetUtcNow().UtcDateTime;
var validAccessToken = userTokens.Documents.FirstOrDefault(token => !token.ExpiresUtc.HasValue || token.ExpiresUtc > utcNow);
var validAccessToken = userTokens.Documents.FirstOrDefault(token => !token.IsDisabled && !token.IsSuspended
&& user.HasValidAuthenticationVersion(token.AuthenticationVersion)
&& (!token.ExpiresUtc.HasValue || token.ExpiresUtc > utcNow));
if (validAccessToken is not null)
return validAccessToken.Id;

var token = await tokenRepository.AddAsync(new Token
{
Id = Core.Extensions.StringExtensions.GetNewToken(),
UserId = user.Id,
AuthenticationVersion = user.AuthenticationVersion,
CreatedUtc = utcNow,
UpdatedUtc = utcNow,
ExpiresUtc = utcNow.AddMonths(3),
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ private async Task<IResult> CompleteAuthorizationAsync(OAuthAuthorizeRequest req
if (!organizationValidation.IsValid)
return OAuthError("invalid_request", organizationValidation.ErrorDescription);

string code = await oauthService.CreateAuthorizationCodeAsync(request, HttpContext.Request.GetUser().Id, organizationValidation.OrganizationIds);
string code = await oauthService.CreateAuthorizationCodeAsync(request, HttpContext.Request.GetUser(), organizationValidation.OrganizationIds);
string redirectUri = BuildRedirectUri(request.RedirectUri, code, request.State, GetOrigin());
return HttpResults.Ok(new OAuthAuthorizeResponse { RedirectUri = redirectUri });
}
Expand Down
6 changes: 6 additions & 0 deletions src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
return AuthenticateResult.Fail("Token is not valid");
}

if (!user.HasValidAuthenticationVersion(tokenRecord.AuthenticationVersion))
return AuthenticateResult.Fail("Token is not valid");
Comment on lines +139 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent stale cache writes from restoring old generations

When token cleanup fails and an authentication request began an uncached user read before a password change, that request can publish the old user snapshot to the cache after SaveAsync has rotated the generation and invalidated the cache. This check then accepts the surviving old token on subsequent requests until the stale cache entry expires or is invalidated again, defeating the revocation fallback; validate the generation against a current read or make cache publication/invalidation ordering safe.

Useful? React with 👍 / 👎.


return AuthenticateResult.Success(CreateUserAuthenticationTicket(user, tokenRecord));
}

Expand Down Expand Up @@ -252,6 +255,9 @@ private async Task<AuthenticateResult> AuthenticateOAuthBearerAsync(string token
return AuthenticateResult.Fail("User is not valid");
}

if (!user.HasValidAuthenticationVersion(tokenRecord.AuthenticationVersion))
return AuthenticateResult.Fail("User is not valid");

var activeOAuthOrganizationIds = user.GetActiveOAuthOrganizationIds(tokenRecord);
if (activeOAuthOrganizationIds.Count == 0)
{
Expand Down
7 changes: 7 additions & 0 deletions tests/Exceptionless.Tests/Api/Data/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -14124,6 +14124,13 @@
},
"description": "The organizations that the user has access to."
},
"authentication_version": {
"type": [
"null",
"string"
],
"description": "Session revocation generation, rotated when a password changes or resets, or an external login is removed or transferred away.\nRemains null until rotation; active users with a null generation accept only matching legacy null token generations.\nThis value does not describe the password hashing format."
},
"password": {
"type": [
"null",
Expand Down
73 changes: 73 additions & 0 deletions tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,46 @@ public async Task BasicAuthentication_MalformedHeader_ReturnsUnauthorized(string
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

[Fact]
public async Task AuthenticateAsync_InactiveUserToken_ReturnsUnauthorized()
{
// Arrange
const string email = "inactive-token-user@exceptionless.test";
const string salt = "1234567890123456";

var user = new User
{
EmailAddress = email,
Password = TestConstants.UserPassword.ToSaltedHash(salt),
Salt = salt,
FullName = "Inactive Token User",
Roles = AuthorizationRoles.AllScopes
};
user.MarkEmailAddressVerified();
await _userRepository.AddAsync(user);

var login = await SendRequestAsAsync<TokenResult>(r => r
.Post()
.AppendPath("auth/login")
.Content(new Login { Email = email, Password = TestConstants.UserPassword })
.StatusCodeShouldBeOk());
Assert.NotNull(login);
Assert.False(String.IsNullOrEmpty(login.Token));

var storedUser = await _userRepository.GetByEmailAddressAsync(email);
Assert.NotNull(storedUser);
await _userRepository.SaveAsync(storedUser with { IsActive = false }, o => o.ImmediateConsistency());

// Act
var response = await SendRequestAsync(r => r
.BearerToken(login.Token)
.AppendPath("users/me")
.StatusCodeShouldBeUnauthorized());

// Assert
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

[Fact]
public async Task BasicAuthentication_PasswordContainingColons_ReturnsCurrentUser()
{
Expand Down Expand Up @@ -1286,6 +1326,39 @@ public async Task PasswordLogin_ThrottlingExpires_LogsInWithoutPasswordResetOrRe
Assert.Equal(HttpStatusCode.OK, allowed.StatusCode);
}

[Fact]
public async Task ChangePassword_StoredOldTokenSurvivesCleanup_CannotAuthenticate()
{
// Arrange
const string newPassword = "ChangedPassword2$";
var login = await SendRequestAsAsync<TokenResult>(r => r.Post().AppendPath("auth/login")
.Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD })
.StatusCodeShouldBeOk());
Assert.NotNull(login);
var oldToken = await _tokenRepository.GetByIdAsync(login.Token, o => o.Cache(false));
Assert.NotNull(oldToken);
await SendRequestAsync(r => r.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeOk());

// Act
var changed = await SendRequestAsAsync<TokenResult>(r => r.Post().AppendPath("auth/change-password")
.BearerToken(login.Token)
.Content(new ChangePasswordModel { CurrentPassword = SampleDataService.TEST_USER_PASSWORD, Password = newPassword })
.StatusCodeShouldBeOk());
// Retain a record as a failed cleanup would, after warming the authentication cache.
await _tokenRepository.AddAsync(oldToken, o => o.ImmediateConsistency());

// Assert
Assert.NotNull(changed);
Assert.NotEqual(login.Token, changed.Token);
await SendRequestAsync(r => r.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeUnauthorized());
await SendRequestAsync(r => r.BearerToken(changed.Token).AppendPath("users/me").StatusCodeShouldBeOk());
await SendRequestAsync(r => r.BasicAuthorization("client", login.Token).AppendPath("users/me").StatusCodeShouldBeUnauthorized());
var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL);
Assert.NotNull(user);
Assert.False(String.IsNullOrEmpty(user.AuthenticationVersion));
Assert.Equal(user.AuthenticationVersion, (await _tokenRepository.GetByIdAsync(changed.Token))!.AuthenticationVersion);
}

[Fact]
public async Task CanChangePasswordAsync()
{
Expand Down
Loading
Loading